Velocity X Components by Aidxn Design

Custom Framer Component · Free, unlicensed

Video Scrub to Corner

A video section that scrubs continuously from full size into a corner thumbnail as you scroll, with floating captions and a click-to-expand theatre view.

videoscrollstickyplayer

This is a standalone Framer Code File — not installed with the CLI, not gated behind a subscription. Copy the whole file and paste it into Framer via Assets → Code → Create Code File.

Property controls

These are Framer's own addPropertyControls fields, which show up as the right-hand properties panel once this is pasted into Framer. They are not React props and have nothing to do with this site's licensed component API.

Panel label Control key Control type
Eyebrow eyebrow String
Heading heading String
Copy copy String
Video video File
Poster poster ResponsiveImage
Aspect aspect Enum
Captions captions Array
Thumb width dockWidth Number
Thumb margin dockMargin Number
Thumb corner dockCorner Enum
Sound on expand unmuteOnExpand Boolean
Autoplay autoPlay Boolean
Loop loop Boolean
Preload preload Enum
Background background Color
Ink ink Color
Accent accent Color

Component source

The complete, unmodified file — copy the whole thing.

tsx
import { addPropertyControls, ControlType } from "framer"
import { useCallback, useEffect, useId, useRef, useState } from "react"

export const META = {
    slug: "video-scrub-to-corner",
    title: "Video Scrub to Corner",
    description: "A video section that scrubs continuously from full size into a corner thumbnail as you scroll, with floating captions and a click-to-expand theatre view.",
    tags: ["video", "scroll", "sticky", "player"],
    category: "custom-framer",
}

/**
 * Video that starts at full size and SCRUBS down into a corner thumbnail as you scroll past
 * it, with floating captions over the picture.
 *
 * HOW THIS DIFFERS FROM `VideoFloatDock.tsx`, because they look similar and are not:
 *   VideoFloatDock SNAPS. The player sits inline and the moment it leaves the screen it jumps
 *   to a fixed mini player — one state to the other, with an animation between them.
 *   THIS SCRUBS. There is no jump. The player travels from its full inline size to the
 *   thumbnail continuously, tied to scroll position: at 40% scrolled it is 40% of the way
 *   there, and scrolling back walks it home. Scroll IS the timeline.
 *
 * WHY SCRUB RATHER THAN SNAP: a snap has to choose a moment to fire, and whatever moment it
 * chooses is wrong for someone scrolling at a different speed — it either fires while the
 * video is still comfortably readable, or long after it has gone. A scrub has no moment to get
 * wrong, and it lets the reader SEE where the thing went, which is what stops a corner player
 * feeling like an ad that appeared out of nowhere. Both patterns are kept: this one is the
 * considered version, VideoFloatDock is the cheap one for a page that only needs the behaviour.
 *
 * THE TRANSFORM IS THE WHOLE TRICK. The stage is `position: fixed` for the ENTIRE scroll, from
 * the first frame, and a `transform` moves and scales it. Animating width/height/top/left
 * would relayout and repaint every frame; a translate+scale is composited and costs nothing.
 * The consequence: the stage is never in the page flow, so a slot holds its space — without it
 * the page below would sit a video higher and then jump.
 *
 * FLOATING CAPTIONS are timed to the CLIP, not to the scroll. They are what makes the
 * thumbnail still say something: by the time the player is 190px wide nobody is reading a
 * control bar and the sound is off, but a five-word line still lands. They counter-scale only
 * PARTIALLY — see the note in the CSS, the cap of 1.9 is the difference between a readable
 * line and a text-shaped smudge.
 *
 * WIDTH IS LOCKED TO `fixed` — do not change it. Per Framer's docs `fixed` means the component
 * "can fill 100% of its size", while `auto` means it "will dictate its own size based on its
 * content". This is built on `width: 100%`, which only resolves under `fixed`.
 *
 * `position: fixed` IS CLIPPED BY AN ANCESTOR WITH `overflow: hidden` — the thumbnail would
 * vanish at the edge of that parent instead of travelling to the corner of the screen. If it
 * disappears mid-scrub on the canvas, that is the first thing to check.
 *
 * THE VIDEO IS A FRAMER FILE PROP, never a relative path. A code component cannot resolve
 * `assets/…` and it breaks on publish.
 *
 * A LONG CLIP NEEDS NO CHUNKING, NO SPLITTING AND NO STREAMING LIBRARY — ONE UPLOAD IS RIGHT,
 * as long as two conditions hold:
 *
 *   1. THE MP4 IS FASTSTART — its `moov` index atom sits at the FRONT of the file, before
 *      `mdat`. If `moov` is at the END (common off a phone, a screen recorder, or a stalled
 *      export) the browser must pull the entire file before it can show frame one, and THAT is
 *      what makes a long video feel broken. The fix is a remux, not an architecture:
 *      `ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4`.
 *   2. THE HOST ANSWERS RANGE REQUESTS — `accept-ranges: bytes` and a `206 Partial Content`
 *      for `.mp4`.
 *
 * With both true the browser fetches the index, starts playing, and pulls the rest in pieces
 * as it goes, seeking by asking for the byte range it needs. Hand-cut splits would be strictly
 * worse: a gap at every join, N uploads to keep in order, and seeking becomes something you
 * have to write yourself. Real chunked delivery is HLS/DASH, which needs a manifest, a
 * segmenter and hls.js — all of it to reimplement what the two conditions above already give.
 *
 * `preload` is the only lever worth having, and it is a property control. `metadata` fetches
 * the index and stops; `auto` lets the browser buffer ahead so the opening seconds are
 * instant. Hero gets `auto`, anything below the fold gets `metadata`.
 *
 * CLICKING THE THUMBNAIL OPENS IT FULL SCREEN, and it is still ONE player. The overlay
 * retargets the same transform the scrub writes, so the clip never restarts and the playhead
 * survives thumbnail → full screen → thumbnail. A second fixed element with its own <video> is
 * the obvious build and the one that loses the playhead on every open.
 *
 * `VideoTranscriptStrip.tsx` is the caption strip for the breadcrumb area and it finds this
 * player by querying the document for `.fc-vs-el`. That class is a contract between the two
 * files — see the note on it in the CSS before renaming anything.
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight any-prefer-fixed
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 900
 * @framerDisableUnlink
 */
export default function VideoScrubToCorner(props) {
    const {
        eyebrow = "See it in action",
        heading = "A closer look at how it works.",
        copy = "A quick walkthrough of what to expect, what we check, and what happens next.",
        video: src = "",
        poster = null,
        aspect = "tall",
        captions = DEFAULT_CAPTIONS,
        dockWidth = 190,
        dockMargin = 20,
        dockCorner = "right",
        unmuteOnExpand = true,
        preload = "metadata",
        autoPlay = true,
        loop = false,
        background = "#F7FAFA",
        ink = "#2F3A48",
        accent = "#F97316",
        style,
    } = props

    const scope = "fc-" + useId().replace(/[^a-zA-Z0-9]/g, "")

    const rootRef = useRef(null)
    const slotRef = useRef(null)
    const stageRef = useRef(null)
    const videoRef = useRef(null)
    // Last travel value written. A ref, not state — this updates on every scroll frame and
    // must never be a reason to re-render.
    const travelled = useRef(-1)

    const [playing, setPlaying] = useState(false)
    const [muted, setMuted] = useState(true)
    const [dismissed, setDismissed] = useState(false)
    const [thumb, setThumb] = useState(false)
    const [theatre, setTheatre] = useState(false)
    const [progress, setProgress] = useState(0)
    const [remaining, setRemaining] = useState(0)
    const [caption, setCaption] = useState(-1)

    // `layout()` runs on every scroll frame and must not close over stale state, so the two
    // flags it branches on are mirrored into refs. Reading state there would either need the
    // callback rebuilt per toggle or silently use the value from the render it was made in.
    const theatreRef = useRef(false)
    const list = Array.isArray(captions) ? captions : []

    /* ---------------------------------------------------------------------
       THE SCRUB
       --------------------------------------------------------------------- */
    const layout = useCallback(() => {
        const slot = slotRef.current
        const stage = stageRef.current
        if (!slot || !stage || dismissed) return

        const r = slot.getBoundingClientRect()
        const vh = window.innerHeight
        const vw = window.innerWidth

        /* THEATRE IS THE SAME ONE TRANSFORM, aimed somewhere else. It is tempting to make the
           overlay its own fixed element with its own copy of the <video>, and that is the
           version that loses the playhead: a second element means a second media element,
           which starts at 0:00 and buffers again. Retargeting the transform keeps ONE video
           playing continuously from thumbnail to full screen and back. The animation between
           the two is a CSS transition on the same property — see `.is-morph` in the CSS. */
        if (theatreRef.current) {
            const startW0 = r.width
            if (!startW0) return
            const pad = Math.min(56, vw * 0.05, vh * 0.05)
            const k = Math.min((vw - pad * 2) / startW0, (vh - pad * 2) / r.height)
            const w = startW0 * k
            const h = r.height * k
            stage.style.width = `${startW0}px`
            stage.style.height = `${r.height}px`
            stage.style.transform = `translate3d(${(vw - w) / 2}px, ${(vh - h) / 2}px, 0) scale(${k})`
            stage.style.setProperty("--inv", String(1 / k))
            // The slot is fully vacated while the overlay is up, so the placeholder is at full.
            rootRef.current?.style.setProperty("--travel", "1")
            return
        }

        /* Travel runs from "the slot's top edge reaches the top of the screen" to "it has
           scrolled a further 70% of a screen". Measuring from the TOP edge rather than the
           bottom is what makes the full-size start hold: the video stays full size for the
           whole time it is comfortably on screen and only begins to shrink once it is trying
           to leave. */
        const t = ease(clamp(-r.top / (vh * 0.7)))
        if (Math.abs(t - travelled.current) < 0.001) return
        travelled.current = t

        const startW = r.width
        if (!startW) return
        const ratio = dockWidth / startW

        /* SHRINK AHEAD OF THE MOVE. Driving scale and position off the same `t` is the obvious
           build and it looks wrong: at the halfway point the player is still most of its full
           size and already a long way down the page, so a near-full-screen video slides over
           the section below it. Running scale ahead means it is small BEFORE it has travelled
           far, which reads as the thing shrinking into the corner rather than sliding down the
           page. 1.45 is a reasonable factor to start from; tune it against your own page. */
        const scale = 1 - (1 - ratio) * ease(clamp(t * 1.45))

        // Transform origin is top-left, so the maths stays plain: from where the box is now, to
        // where its top-left corner has to be for the scaled box to land in the corner.
        const endX =
            dockCorner === "left" ? dockMargin : vw - dockWidth - dockMargin
        const endY = vh - r.height * ratio - dockMargin

        stage.style.width = `${startW}px`
        stage.style.height = `${r.height}px`
        stage.style.transform = `translate3d(${r.left + (endX - r.left) * t}px, ${
            r.top + (endY - r.top) * t
        }px, 0) scale(${scale})`
        // Counter-scale factor for the chrome. Without it a 32px button is 11px by the time the
        // player reaches thumbnail size — visible, and completely unhittable on a phone.
        stage.style.setProperty("--inv", String(1 / scale))
        // Drives the placeholder still in the slot. Same value, so the hole fills in at exactly
        // the rate the picture vacates it.
        rootRef.current?.style.setProperty("--travel", t.toFixed(3))

        setThumb(t > 0.6)
    }, [dismissed, dockWidth, dockMargin, dockCorner])

    useEffect(() => {
        let frame = 0
        /* CANCEL AND RESCHEDULE, rather than gating on a `queued` boolean that is only
           cleared inside the callback. The boolean version has a latch in it: if the frame it
           queues never runs, `queued` stays true and every later scroll is swallowed for the
           life of the page — the player sits at whatever size it had and never moves again,
           with nothing in the console. `cancelAnimationFrame` on a stale handle is a no-op, so
           this version cannot latch and costs nothing. */
        const onScroll = () => {
            cancelAnimationFrame(frame)
            frame = requestAnimationFrame(layout)
        }
        const onResize = () => {
            travelled.current = -1 // force a recompute; every measurement is viewport-relative
            layout()
        }
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onResize)
        // Coming back to a backgrounded tab, the stage is wherever it froze and the scroll
        // position has usually moved. Recompute on return rather than waiting for the next
        // scroll, which on a page already at the bottom may never come.
        document.addEventListener("visibilitychange", onResize)
        layout()
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onResize)
            document.removeEventListener("visibilitychange", onResize)
            cancelAnimationFrame(frame)
        }
    }, [layout, src, aspect])

    /* ---------------------------------------------------------------------
       THEATRE
       --------------------------------------------------------------------- */
    const [morphing, setMorphing] = useState(false)
    const openTheatre = useCallback(() => {
        if (theatreRef.current) return
        theatreRef.current = true
        travelled.current = -1 // the guard holds a scrub value; force the next write through
        setTheatre(true)
        setMorphing(true)

        /* SOUND ON, BUT NEVER AT THE COST OF PLAYBACK. Expanding is an explicit click, which
           is the invitation this component otherwise refuses to assume — so sound comes on
           here and nowhere else.

           Two things make this fiddly and both have bitten already:

           1. `el.muted` IS SET ON THE ELEMENT, not just through React state. The autoplay
              policy reads the DOM property during the gesture's tick; a `setMuted(false)`
              lands a render later, by which point the gesture is spent.
           2. IF UNMUTED PLAYBACK IS REFUSED, FALL BACK TO MUTED. Browsers block audible
              playback without enough user activation, and the failure mode is the worst one
              available: a paused black rectangle filling the screen with no explanation.
              Muted-and-playing beats silent-and-frozen every time. */
        const el = videoRef.current
        if (el && unmuteOnExpand) {
            el.muted = false
            setMuted(false)
        }
        el?.play().catch(() => {
            if (!el) return
            el.muted = true
            setMuted(true)
            el.play().catch(() => {})
        })
        layout()
    }, [layout, unmuteOnExpand])

    const closeTheatre = useCallback(() => {
        if (!theatreRef.current) return
        theatreRef.current = false
        travelled.current = -1
        setTheatre(false)
        setMorphing(true)
        layout()
    }, [layout])

    // The morph is a CSS transition on the stage's transform, switched on for its duration and
    // off again. Leaving it on permanently would put a 500ms lag on the scroll scrub, which is
    // the one thing the scrub cannot have — it has to track the finger exactly.
    useEffect(() => {
        if (!morphing) return
        const id = setTimeout(() => setMorphing(false), 520)
        return () => clearTimeout(id)
    }, [morphing])

    // Lock the page behind the overlay, and pad for the scrollbar the lock removes — without
    // the padding the whole page shifts sideways as the overlay opens.
    useEffect(() => {
        if (!theatre) return
        const body = document.body
        const gap = window.innerWidth - document.documentElement.clientWidth
        const prevOverflow = body.style.overflow
        const prevPad = body.style.paddingRight
        body.style.overflow = "hidden"
        if (gap > 0) body.style.paddingRight = `${gap}px`
        const onKey = (e) => { if (e.key === "Escape") closeTheatre() }
        window.addEventListener("keydown", onKey)
        return () => {
            body.style.overflow = prevOverflow
            body.style.paddingRight = prevPad
            window.removeEventListener("keydown", onKey)
        }
    }, [theatre, closeTheatre])

    /* ---------------------------------------------------------------------
       PLAYBACK
       --------------------------------------------------------------------- */
    const play = () => videoRef.current?.play().catch(() => {})

    const onTime = () => {
        const el = videoRef.current
        if (!el) return
        setProgress(el.duration ? (el.currentTime / el.duration) * 100 : 0)
        setRemaining(el.duration - el.currentTime)

        // Last caption whose window contains the playhead. A plain loop — a handful of items
        // does not need an index, and an index is one more thing to keep in step.
        let next = -1
        for (let i = 0; i < list.length; i++) {
            const c = list[i]
            if (el.currentTime >= (c.at || 0) && el.currentTime < (c.until || 0)) next = i
        }
        setCaption((prev) => (prev === next ? prev : next))
    }

    const seek = (clientX, track) => {
        const el = videoRef.current
        if (!el || !el.duration) return
        const b = track.getBoundingClientRect()
        el.currentTime = clamp((clientX - b.left) / b.width) * el.duration
    }

    if (!src) return <Empty style={style} />

    const classes = [
        "fc-vs",
        playing ? "is-playing" : "",
        muted ? "is-muted" : "",
        thumb ? "is-thumb" : "",
        theatre ? "is-theatre" : "",
        morphing ? "is-morph" : "",
        dismissed ? "is-dismissed" : "",
    ]
        .filter(Boolean)
        .join(" ")

    return (
        <div className={scope} style={style} ref={rootRef}>
            <style>
                {css(scope, { background, ink, accent, dockWidth, dockMargin })}
            </style>
            <section
                className={classes}
                data-aspect={aspect}
                /* A HERO HAS NO ROOM FOR SECTION PADDING. With no eyebrow, heading or copy set
                   — which is how this gets used in a hero, where the page's own headline does
                   that job — the section's padding is pure dead white above and below the
                   picture. Nothing to set on the canvas: if there is no copy, there is nothing
                   for the padding to separate. */
                data-bare={eyebrow || heading || copy ? undefined : "true"}
                aria-labelledby={`${scope}-heading`}
            >
                {eyebrow || heading || copy ? (
                    <div className="fc-vs-copy">
                        {eyebrow ? <span className="fc-vs-eyebrow">{eyebrow}</span> : null}
                        {heading ? (
                            <h2 className="fc-vs-heading" id={`${scope}-heading`}>
                                {heading}
                            </h2>
                        ) : null}
                        {copy ? <p className="fc-vs-lede">{copy}</p> : null}
                    </div>
                ) : null}

                {/* The slot holds the page's space for the whole scroll. The stage is fixed
                    from the first frame, so it is never in flow and this is the only thing
                    keeping the page from collapsing upwards by the height of a video. */}
                {/* The scrim is a sibling of the slot and NOT a parent of the stage. Wrapping
                    the stage in it would give the stage a new containing block and a new
                    stacking context mid-animation, which re-anchors a `position: fixed`
                    element and makes the morph jump. */}
                <div
                    className="fc-vs-scrim"
                    onClick={closeTheatre}
                    aria-hidden={theatre ? undefined : "true"}
                />

                <div className="fc-vs-slot" ref={slotRef}>
                    {/* WHAT THE PICTURE LEAVES BEHIND. The slot has to hold the page's space
                        for the whole scroll, so the moment the stage travels off toward the
                        corner there is a video-shaped hole in the hero. On a white page that
                        hole is a bright empty rectangle and it reads as a component that
                        failed to load. A dimmed still of the clip turns it into an obvious
                        placeholder: you can see what used to be there and that it went
                        somewhere. It fades IN as the stage leaves, driven by the same travel
                        value. */}
                    {poster?.src ? (
                        <img className="fc-vs-ghost" src={poster.src} alt="" aria-hidden="true" />
                    ) : null}
                    <div className="fc-vs-stage" ref={stageRef}>
                        <video
                            className="fc-vs-el"
                            ref={videoRef}
                            src={src}
                            poster={poster?.src || undefined}
                            playsInline
                            autoPlay={autoPlay}
                            loop={loop}
                            muted={muted}
                            preload={autoPlay || preload === "auto" ? "auto" : "metadata"}
                            onPlay={() => setPlaying(true)}
                            onPause={() => setPlaying(false)}
                            onTimeUpdate={onTime}
                            onLoadedMetadata={() => {
                                setRemaining(videoRef.current?.duration || 0)
                                layout()
                            }}
                        />

                        {/* Captions sit INSIDE the stage so they travel and scale with the
                            picture. A caption in a fixed overlay outside would stay put while
                            the video shrank away from under it. */}
                        <div className="fc-vs-captions" aria-live="polite">
                            {list.map((c, i) => (
                                <span
                                    className={"fc-vs-cap" + (i === caption ? " is-on" : "")}
                                    key={i}
                                >
                                    {c.text}
                                </span>
                            ))}
                        </div>

                        <button
                            className="fc-vs-poster"
                            type="button"
                            onClick={play}
                            aria-label="Play video"
                            tabIndex={playing ? -1 : 0}
                        >
                            <span className="fc-vs-bigplay">
                                <Icon name="play" />
                            </span>
                        </button>

                        <div className="fc-vs-bar">
                            <button
                                className="fc-vs-btn"
                                type="button"
                                aria-label={playing ? "Pause" : "Play"}
                                onClick={() =>
                                    videoRef.current?.paused ? play() : videoRef.current?.pause()
                                }
                            >
                                <Icon name={playing ? "pause" : "play"} />
                            </button>

                            <div
                                className="fc-vs-track"
                                role="slider"
                                tabIndex={0}
                                aria-label="Seek"
                                aria-valuemin={0}
                                aria-valuemax={100}
                                aria-valuenow={Math.round(progress)}
                                onPointerDown={(e) => {
                                    e.currentTarget.setPointerCapture(e.pointerId)
                                    seek(e.clientX, e.currentTarget)
                                }}
                                onPointerMove={(e) => {
                                    if (e.currentTarget.hasPointerCapture(e.pointerId))
                                        seek(e.clientX, e.currentTarget)
                                }}
                                onKeyDown={(e) => {
                                    const step =
                                        e.key === "ArrowRight" ? 5 : e.key === "ArrowLeft" ? -5 : 0
                                    if (!step) return
                                    e.preventDefault()
                                    const el = videoRef.current
                                    if (el)
                                        el.currentTime = clamp(
                                            el.currentTime + step,
                                            0,
                                            el.duration || 0
                                        )
                                }}
                            >
                                <span className="fc-vs-fill" style={{ width: `${progress}%` }} />
                            </div>

                            <span className="fc-vs-time">{fmt(remaining)}</span>

                            {/* Sound is only ever turned on by a person. We never unmute on
                                their behalf — an autoplaying voice loses a reader instantly. */}
                            <button
                                className="fc-vs-btn"
                                type="button"
                                aria-label={muted ? "Unmute" : "Mute"}
                                onClick={() => setMuted((m) => !m)}
                            >
                                <Icon name={muted ? "muted" : "sound"} />
                            </button>
                        </div>

                        {/* THE WHOLE THUMBNAIL IS THE EXPAND TARGET, not just the chip. A 26px
                            chip on a 190px player is a hard thing to hit on a phone, and the
                            picture is what someone actually aims at. The chip stays because it
                            is the only thing that SAYS the thumbnail does something. */}
                        <button
                            className="fc-vs-grow"
                            type="button"
                            aria-label="Expand video"
                            onClick={openTheatre}
                            tabIndex={thumb && !theatre ? 0 : -1}
                        />

                        {/* Chips sit in the TOP corners so the bottom of the frame stays free for
                            burned-in subtitles or a disclaimer in your own source footage. */}
                        <button
                            className="fc-vs-chip fc-vs-back"
                            type="button"
                            aria-label={theatre ? "Shrink video" : "Expand video"}
                            onClick={theatre ? closeTheatre : openTheatre}
                        >
                            <Icon name={theatre ? "shrink" : "expand"} />
                        </button>
                        <button
                            className="fc-vs-chip fc-vs-close"
                            type="button"
                            aria-label={theatre ? "Close video" : "Close mini player"}
                            onClick={() => {
                                if (theatre) { closeTheatre(); return }
                                videoRef.current?.pause()
                                setDismissed(true)
                            }}
                        >
                            <Icon name="close" />
                        </button>
                    </div>
                </div>
            </section>
        </div>
    )
}

/* KEEP CAPTIONS SHORT. These have to be readable in a 190px-wide thumbnail, which is about six
   words before the line stops being a line. Times are seconds against the clip. */
const DEFAULT_CAPTIONS = [
    { at: 0.4, until: 4.5, text: "Every project gets the same check" },
    { at: 5, until: 9.5, text: "Details you wouldn't spot at a glance" },
    { at: 10, until: 15, text: "We document everything we find" },
    { at: 15.5, until: 21, text: "You get a clear written summary" },
    { at: 21.5, until: 28.5, text: "Free, and no obligation to proceed" },
]

const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v))
// Ease the travel so it leaves slowly and arrives slowly. A linear scrub is the giveaway that
// something is welded to a scrollbar; eased, it reads as a decision the page made.
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)

function fmt(s) {
    if (!Number.isFinite(s) || s < 0) return "0:00"
    return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`
}

function Icon({ name }) {
    const solid = name === "play"
    return (
        <svg
            viewBox="0 0 24 24"
            fill={solid ? "currentColor" : "none"}
            stroke={solid ? "none" : "currentColor"}
            strokeWidth="1.7"
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden="true"
        >
            {name === "play" ? (
                <path d="M8 5.5v13l11-6.5z" />
            ) : name === "pause" ? (
                <path d="M9 5.5v13M15 5.5v13" />
            ) : name === "sound" ? (
                <>
                    <path d="M11 5 6.5 9H3v6h3.5L11 19z" />
                    <path d="M15.5 9.2a4 4 0 0 1 0 5.6" />
                    <path d="M18 6.8a7.5 7.5 0 0 1 0 10.4" />
                </>
            ) : name === "muted" ? (
                <>
                    <path d="M11 5 6.5 9H3v6h3.5L11 19z" />
                    <path d="m16 10 5 4M21 10l-5 4" />
                </>
            ) : name === "expand" ? (
                <>
                    <path d="M4 9V4h5M20 15v5h-5" />
                    <path d="m4 4 6 6M20 20l-6-6" />
                </>
            ) : name === "shrink" ? (
                <>
                    <path d="M10 4v5H5M14 20v-5h5" />
                    <path d="m4 10 5-1 1-5M20 14l-5 1-1 5" />
                </>
            ) : (
                <path d="M6 6l12 12M18 6 6 18" />
            )}
        </svg>
    )
}

function Empty({ style }) {
    return (
        <div
            style={{
                ...style,
                display: "grid",
                placeItems: "center",
                minHeight: 160,
                background: "#F4F5F6",
                color: "#2F3A48",
                font: "500 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
                textAlign: "center",
                padding: 20,
            }}
        >
            Upload a video in the properties panel.
        </div>
    )
}

function css(scope, { background, ink, accent, dockWidth, dockMargin }) {
    const s = `.${scope}`
    return `
/* EVERYTHING GEOMETRIC IS SET FROM JS. Width, height and transform on the stage are written
   every frame by the scroll handler — do not set them here, a CSS width would be overwritten on
   the first scroll and look like a bug that comes and goes. What lives here is the look. */
${s} { container: fc-vs-shell / inline-size; width: 100%; min-width: 320px; }
${s} .fc-vs {
  --fc-content-max: 1356px;
  --fc-gutter: 30px;
  --fc-edge: max(var(--fc-gutter), calc((100% - var(--fc-content-max)) / 2 + var(--fc-gutter)));
  container: fc-vs / inline-size;
  box-sizing: border-box;
  width: 100%;
  padding: clamp(46px, 7cqi, 96px) var(--fc-edge) clamp(52px, 8cqi, 104px);
  background: ${background};
  color: ${ink};
  font-stretch: 100%;
  -webkit-font-smoothing: antialiased;
  --dock-w: ${dockWidth}px;
  --dock-margin: ${dockMargin}px;
}
${s} .fc-vs *, ${s} .fc-vs *::before, ${s} .fc-vs *::after { box-sizing: border-box; }
/* No eyebrow, heading or copy means this is sitting in a hero where the page supplies all
   three. There is then nothing for the section padding to separate, and it is just dead white
   above and below the picture — so it comes off. Horizontal padding stays: that is the shared
   page grid, and the picture still has to line up with everything else on the page. */
${s} .fc-vs[data-bare] { padding-top: 0; padding-bottom: 0; }

${s} .fc-vs-copy { max-width: 60ch; margin: 0 0 clamp(20px, 2.6cqi, 32px); }
${s} .fc-vs-eyebrow {
  display: block;
  margin-bottom: 10px;
  color: ${accent};
  font-size: clamp(11px, 1.1cqi, 13px);
  font-weight: 600;
  letter-spacing: .09em;
  text-transform: uppercase;
}
${s} .fc-vs-heading {
  margin: 0;
  font-size: clamp(24px, 3.2cqi, 40px);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -.035em;
}
${s} .fc-vs-lede {
  margin: clamp(10px, 1.3cqi, 16px) 0 0;
  color: color-mix(in srgb, ${ink} 66%, ${background});
  font-size: clamp(14px, 1.5cqi, 18px);
  line-height: 1.55;
}

/* Aspect follows the SOURCE. Forcing 16:9 on a vertical clip crops the presenter's head off.
   \`--slot-max\` is why a 9:16 clip does not start 1200px wide and three screens tall — "full
   size" for a vertical clip is a tall column, not a full-bleed banner. */
${s} .fc-vs[data-aspect="wide"]   { --ar: 16 / 9; --slot-max: none; }
${s} .fc-vs[data-aspect="square"] { --ar: 1 / 1;  --slot-max: 520px; }
${s} .fc-vs[data-aspect="tall"]   { --ar: 9 / 16; --slot-max: 420px; }

/* THE SLOT IS A WELL, NOT A GAP. It holds the page's space for the whole scroll, so once the
   stage travels off it is the thing left behind — and an untinted one on a white page is a
   bright empty rectangle that reads as a failed load. The tint and the radius make it look
   like somewhere the picture came FROM. */
${s} .fc-vs-slot {
  position: relative;
  width: 100%;
  max-width: var(--slot-max, none);
  aspect-ratio: var(--ar, 16 / 9);
  border-radius: 16px;
  background: color-mix(in srgb, ${ink} 7%, transparent);
  overflow: hidden;
}
/* The dimmed still underneath. Fades in at exactly the rate the stage vacates the slot, so
   there is never a frame where the hero has a hole in it. Capped well below full strength —
   it is a placeholder, and at full opacity it reads as a second video that someone forgot to
   start, which is worse than the hole. */
${s} .fc-vs-ghost {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: calc(var(--travel, 0) * 0.3);
  filter: grayscale(.35);
  pointer-events: none;
}

/* FIXED FROM THE START at top/left 0, positioned entirely by transform. This is what makes the
   scrub cheap: translate and scale are composited, so no frame of it touches layout.
   \`will-change\` is set once and left alone rather than toggled — toggling it mid-scroll costs
   a layer promotion at exactly the moment there is no spare frame for one. */
${s} .fc-vs-stage {
  position: fixed;
  top: 0;
  left: 0;
  transform-origin: 0 0;
  overflow: hidden;
  border-radius: 16px;
  background: ${ink};
  box-shadow: 0 18px 44px color-mix(in srgb, ${ink} 22%, transparent);
  will-change: transform;
  z-index: 40;
  --inv: 1;
}
${s} .fc-vs.is-thumb .fc-vs-stage { z-index: 60; }
${s} .fc-vs.is-dismissed .fc-vs-stage { display: none; }
/* ⚠ \`fc-vs-el\` IS A PUBLIC NAME, NOT AN INTERNAL ONE. \`VideoTranscriptStrip.tsx\` finds this
   player by querying the document for \`.fc-vs-el\` — that class IS the wiring between the two
   components, chosen over a matching id typed into both panels precisely because there is
   nothing on the canvas left to set wrong. Rename it and the transcript silently stops
   following, with no error anywhere. If it ever has to change, change it in both files. */
${s} .fc-vs-el { display: block; width: 100%; height: 100%; object-fit: cover; }

/* THEATRE ------------------------------------------------------------------------------------
   The overlay is two additions and nothing else: a scrim, and a transition on the transform
   the scrub already writes. There is no second player and no second layout. */
${s} .fc-vs-scrim {
  position: fixed;
  inset: 0;
  z-index: 70;
  background: color-mix(in srgb, ${ink} 88%, transparent);
  backdrop-filter: blur(6px);
  opacity: 0;
  pointer-events: none;
  transition: opacity .42s ease;
}
${s} .fc-vs.is-theatre .fc-vs-scrim { opacity: 1; pointer-events: auto; }
${s} .fc-vs.is-theatre .fc-vs-stage { z-index: 80; }
/* \`contain\` off and the shadow dropped at full screen — a 44px shadow around a viewport-sized
   box renders as a grey halo on the scrim, not as depth. */
${s} .fc-vs.is-theatre .fc-vs-stage { box-shadow: none; }

/* ONLY DURING THE MORPH. A permanent transition here would put half a second of lag on the
   scroll scrub, and the scrub's entire job is to track the finger exactly. */
${s} .fc-vs.is-morph .fc-vs-stage {
  transition: transform .5s cubic-bezier(.22, 1, .36, 1);
}

/* The expand hit area covers the whole thumbnail. It sits UNDER the chips and the control bar
   in the stacking order, so the close chip and the scrubber still win their own clicks. */
${s} .fc-vs-grow {
  position: absolute;
  inset: 0;
  z-index: 1;
  border: 0;
  padding: 0;
  background: transparent;
  cursor: zoom-in;
  opacity: 0;
  pointer-events: none;
}
${s} .fc-vs.is-thumb .fc-vs-grow { pointer-events: auto; }
${s} .fc-vs.is-theatre .fc-vs-grow { pointer-events: none; }
${s} .fc-vs-grow:focus-visible { opacity: 1; outline: 2px solid ${accent}; outline-offset: -4px; }

/* FLOATING CAPTIONS. Part of the frame, not chrome on top of it — at thumbnail size they are
   the only thing still saying anything, because the bar is gone and the sound is off. */
${s} .fc-vs-captions {
  position: absolute;
  bottom: 20%;
  left: 6%;
  display: grid;
  pointer-events: none;

  /* A PARTIAL COUNTER-SCALE, and the number matters. Letting the captions shrink with the
     picture is right in principle and unreadable in practice: at thumbnail the stage is about
     0.45, so a 19px line renders at 6px — a text-shaped smudge. Counter-scaling them fully
     like the buttons is also wrong; they would end up as big as the thumbnail. Capping at 1.9
     means they still visibly shrink with the frame but never below roughly 16px. Width is
     divided back out so a scaled-up box cannot overflow the frame. */
  --cap-inv: min(var(--inv), 1.9);
  width: calc(88% / var(--cap-inv));
  transform: scale(var(--cap-inv));
  transform-origin: 0 100%;
}
${s} .fc-vs-cap {
  /* Every caption occupies the same grid cell, so they cross-fade in place instead of pushing
     each other around and reflowing the line. */
  grid-area: 1 / 1;
  align-self: end;
  justify-self: start;
  padding: .42em .72em;
  border-radius: 10px;
  background: color-mix(in srgb, ${ink} 82%, transparent);
  color: #fff;
  font-size: clamp(13px, 1.5cqi, 19px);
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -.015em;
  text-wrap: balance;
  backdrop-filter: blur(8px);
  opacity: 0;
  transform: translateY(6px);
  transition: opacity .35s ease, transform .45s cubic-bezier(.2, .7, .2, 1);
}
${s} .fc-vs-cap.is-on { opacity: 1; transform: none; }
/* \`bottom: 20%\` is a clearance for any burned-in subtitles or disclaimer baked into your own
   source footage — measure against your real clip before moving it. */

${s} .fc-vs-poster {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border: 0;
  background: linear-gradient(180deg,
    color-mix(in srgb, ${ink} 16%, transparent),
    color-mix(in srgb, ${ink} 52%, transparent));
  color: #fff;
  cursor: pointer;
  font: inherit;
  transition: opacity .3s ease;
}
${s} .fc-vs.is-playing .fc-vs-poster { opacity: 0; pointer-events: none; }
${s} .fc-vs-bigplay {
  display: grid;
  place-items: center;
  width: clamp(56px, 7cqi, 78px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: #fff;
  color: ${ink};
  box-shadow: 0 10px 30px rgba(0, 0, 0, .28);
  transition: transform .22s cubic-bezier(.2, .7, .2, 1);
}
${s} .fc-vs-bigplay svg { width: 44%; height: auto; }
${s} .fc-vs-poster:hover .fc-vs-bigplay { transform: scale(1.06); }

/* The bar and the chips COUNTER-SCALE fully. Without it a 32px button is 11px by the time the
   player reaches thumbnail size — visible, and completely unhittable on a phone. */
${s} .fc-vs-bar {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: clamp(10px, 1.4cqi, 16px);
  background: linear-gradient(0deg, color-mix(in srgb, ${ink} 80%, transparent), transparent);
  color: #fff;
  opacity: 0;
  width: calc(100% / var(--inv));
  transform: scale(var(--inv));
  transform-origin: 0 100%;
  transition: opacity .25s ease;
}
${s} .fc-vs.is-playing .fc-vs-bar,
${s} .fc-vs-stage:hover .fc-vs-bar,
${s} .fc-vs-stage:focus-within .fc-vs-bar { opacity: 1; }
${s} .fc-vs.is-thumb .fc-vs-bar { opacity: 0; }
${s} .fc-vs.is-thumb .fc-vs-stage:hover .fc-vs-bar,
${s} .fc-vs.is-thumb .fc-vs-stage:focus-within .fc-vs-bar { opacity: 1; }
${s} .fc-vs.is-thumb .fc-vs-time { display: none; }
/* At full screen the bar is the point — someone opened this to watch it. It stays up, and the
   clock comes back even if the thumbnail rules above had hidden it. */
${s} .fc-vs.is-theatre .fc-vs-bar { opacity: 1; z-index: 3; }
${s} .fc-vs.is-theatre .fc-vs-time { display: block; }

${s} .fc-vs-btn {
  display: grid;
  place-items: center;
  flex: none;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: rgba(255, 255, 255, .14);
  color: #fff;
  cursor: pointer;
}
${s} .fc-vs-btn:hover { background: rgba(255, 255, 255, .24); }
${s} .fc-vs-btn:focus-visible { outline: 2px solid ${accent}; outline-offset: 2px; }
${s} .fc-vs-btn svg { width: 17px; height: 17px; }

${s} .fc-vs-track {
  position: relative;
  flex: 1;
  height: 4px;
  border-radius: 99px;
  background: rgba(255, 255, 255, .26);
  cursor: pointer;
  touch-action: none;
}
${s} .fc-vs-track:focus-visible { outline: 2px solid ${accent}; outline-offset: 5px; }
${s} .fc-vs-fill { display: block; height: 100%; border-radius: inherit; background: ${accent}; }
${s} .fc-vs-time {
  flex: none;
  min-width: 38px;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* Thumbnail chips. TOP corners only. */
${s} .fc-vs-chip {
  position: absolute;
  top: 6px;
  display: none;
  place-items: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: color-mix(in srgb, ${ink} 72%, transparent);
  color: #fff;
  cursor: pointer;
  backdrop-filter: blur(6px);
  transform: scale(var(--inv));
}
${s} .fc-vs-close { right: 6px; transform-origin: 100% 0; }
${s} .fc-vs-back { left: 6px; transform-origin: 0 0; }
${s} .fc-vs-chip:hover { background: color-mix(in srgb, ${ink} 90%, transparent); }
${s} .fc-vs-chip:focus-visible { outline: 2px solid ${accent}; outline-offset: 2px; }
${s} .fc-vs-chip svg { width: 14px; height: 14px; }
/* Above the expand hit area, or the chip's own click opens theatre instead of closing it. */
${s} .fc-vs-chip { z-index: 4; }
/* Bigger at full screen, where they are the only way out and nothing is counter-scaled down. */
${s} .fc-vs.is-theatre .fc-vs-chip { display: grid; top: 14px; width: 36px; height: 36px; }
${s} .fc-vs.is-theatre .fc-vs-close { right: 14px; }
${s} .fc-vs.is-theatre .fc-vs-back { left: 14px; }
${s} .fc-vs.is-theatre .fc-vs-chip svg { width: 18px; height: 18px; }
${s} .fc-vs.is-thumb .fc-vs-chip { display: grid; }

@container fc-vs-shell (max-width: 767px) {
  ${s} .fc-vs { --fc-gutter: 15px; }
}

@container fc-vs (max-width: 520px) {
  ${s} .fc-vs-bar { gap: 8px; }
  ${s} .fc-vs-time { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  ${s} .fc-vs-cap { transition-duration: 1ms; transform: none; }
}
`
}

addPropertyControls(VideoScrubToCorner, {
    eyebrow: { type: ControlType.String, title: "Eyebrow", defaultValue: "See it in action" },
    heading: {
        type: ControlType.String,
        title: "Heading",
        displayTextArea: true,
        defaultValue: "A closer look at how it works.",
    },
    copy: {
        type: ControlType.String,
        title: "Copy",
        displayTextArea: true,
        defaultValue:
            "A quick walkthrough of what to expect, what we check, and what happens next.",
    },
    video: {
        type: ControlType.File,
        title: "Video",
        allowedFileTypes: ["mp4", "webm", "mov"],
        description:
            "Upload the clip here. A relative path will not resolve in a code component and breaks on publish.",
    },
    poster: {
        type: ControlType.ResponsiveImage,
        title: "Poster",
        description: "Optional. Without it the first frame shows once metadata loads.",
    },
    aspect: {
        type: ControlType.Enum,
        title: "Aspect",
        options: ["tall", "square", "wide"],
        optionTitles: ["Tall 9:16", "Square 1:1", "Wide 16:9"],
        defaultValue: "tall",
        description: "Match the SOURCE clip. 16:9 on a vertical clip crops the subject's head off.",
    },
    captions: {
        type: ControlType.Array,
        title: "Captions",
        control: {
            type: ControlType.Object,
            controls: {
                text: { type: ControlType.String, title: "Text", defaultValue: "" },
                at: { type: ControlType.Number, title: "From", defaultValue: 0, min: 0, max: 600, step: 0.5, unit: "s" },
                until: { type: ControlType.Number, title: "To", defaultValue: 4, min: 0, max: 600, step: 0.5, unit: "s" },
            },
        },
        defaultValue: DEFAULT_CAPTIONS,
        description:
            "Timed to the clip, not the scroll. Keep each to about six words — they have to survive at thumbnail size, where they are the only thing still saying anything.",
    },
    dockWidth: {
        type: ControlType.Number,
        title: "Thumb width",
        defaultValue: 190,
        min: 120,
        max: 420,
        step: 10,
        unit: "px",
    },
    dockMargin: {
        type: ControlType.Number,
        title: "Thumb margin",
        defaultValue: 20,
        min: 0,
        max: 64,
        step: 2,
        unit: "px",
    },
    dockCorner: {
        type: ControlType.Enum,
        title: "Thumb corner",
        options: ["right", "left"],
        optionTitles: ["Bottom right", "Bottom left"],
        defaultValue: "right",
    },
    unmuteOnExpand: {
        type: ControlType.Boolean,
        title: "Sound on expand",
        defaultValue: true,
        description:
            "Turns sound on when someone expands the thumbnail to full screen. That click is the invitation — nothing here ever unmutes on its own.",
    },
    autoPlay: {
        type: ControlType.Boolean,
        title: "Autoplay",
        defaultValue: true,
        description:
            "Starts muted on load, which is the only kind of autoplay browsers allow. Off means the page shows a play button and nothing moves until someone presses it.",
    },
    loop: {
        type: ControlType.Boolean,
        title: "Loop",
        defaultValue: false,
        description:
            "Right for a short b-roll clip. Wrong for anything with someone talking — a three-minute testimonial restarting itself in the corner is maddening.",
    },
    preload: {
        type: ControlType.Enum,
        title: "Preload",
        options: ["metadata", "auto"],
        optionTitles: ["Index only", "Buffer ahead"],
        defaultValue: "metadata",
        description:
            "Index only downloads a few hundred KB and nothing more until someone presses play — right for a video further down a page. Buffer ahead starts filling the buffer on load so the first seconds are instant; use it in a hero, where the video is the point. Autoplay forces Buffer ahead regardless, because a video that starts on its own has to have something to start with.",
    },

    background: { type: ControlType.Color, title: "Background", defaultValue: "#F7FAFA" },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#2F3A48" },
    accent: {
        type: ControlType.Color,
        title: "Accent",
        defaultValue: "#F97316",
    },
})
  1. 1. In Framer: Assets → Code → Create Code File.
  2. 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.