Velocity X Components by Aidxn Design

Custom Framer Component · Free, unlicensed

Video Float Dock

An inline video that docks into a floating mini player when it scrolls out of view while playing, and returns when you scroll back.

videoscrollfloatplayer

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
Caption caption String
Unmute label unmuteLabel String
Aspect aspect Enum
Dock on scroll dock Boolean
Autoplay autoPlay Boolean
Loop loop Boolean
Dock corner dockCorner 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 { useEffect, useId, useRef, useState } from "react"

export const META = {
    slug: "video-float-dock",
    title: "Video Float Dock",
    description: "An inline video that docks into a floating mini player when it scrolls out of view while playing, and returns when you scroll back.",
    tags: ["video", "scroll", "float", "player"],
    category: "custom-framer",
}

/**
 * Video section that softly docks into a mini player when the viewport's top edge crosses the
 * inline video's midpoint, then returns when the reader scrolls back above it.
 *
 * This is the behaviour every news site uses — once the inline player leaves the screen WHILE
 * PLAYING, it docks to the corner so the video keeps running while the reader keeps scrolling.
 *
 * THE RULE THAT MAKES IT NOT ANNOYING: it only ever docks if the video is ACTUALLY PLAYING. A
 * paused video that scrolls off screen does nothing. Nothing appears uninvited, and a dismiss
 * is remembered for the rest of the page view, so a reader can refuse it once and be left
 * alone. Autoplay is muted (required by browsers), and sound only ever comes on because
 * someone tapped the sound button.
 *
 * ASPECT FOLLOWS THE SOURCE, IT IS NOT ASSUMED. A 16:9 stage crops a vertical clip badly, so
 * `aspect` is a real control and defaults to Tall. Each shape carries its own docked width — a
 * 9:16 clip docked at 340px wide would be half the screen high.
 *
 * THE CLOSE CHIP SITS TOP-RIGHT, NOT IN THE CONTROL BAR, and the bar auto-hides when docked —
 * that leaves the bottom of the frame clear for any burned-in subtitles or disclaimer baked
 * into your own source footage. Do not move the chip back into the bar.
 *
 * 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`. In `auto` the
 * container shrink-wraps, 100% computes to 0, and the section collapses to a thin strip.
 *
 * THE VIDEO IS A FRAMER FILE PROP, never a relative path. A code component cannot resolve
 * `assets/…` and it breaks on publish. Upload the clip in the properties panel.
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight any-prefer-fixed
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 720
 * @framerDisableUnlink
 */
export default function VideoFloatDock(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,
        caption = "Presenter name, role",
        unmuteLabel = "Click to unmute",
        aspect = "tall",
        autoPlay = true,
        loop = true,
        dock = true,
        dockCorner = "left",
        background = "#FFFFFF",
        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 videoRef = useRef(null)
    // A dismiss is remembered for the rest of the page view. Someone who says no once should
    // not have to say it again on every scroll. A ref, not state — flipping it must not
    // re-render mid-scroll.
    const refused = useRef(false)
    const dockedRef = useRef(false)
    const returnTimerRef = useRef(0)

    const [playing, setPlaying] = useState(false)
    const [muted, setMuted] = useState(true)
    const [docked, setDocked] = useState(false)
    const [returning, setReturning] = useState(false)
    const [progress, setProgress] = useState(0)
    const [remaining, setRemaining] = useState(0)

    /* Dock when the viewport's TOP edge crosses the inline video's midpoint. Scroll work is
       batched into requestAnimationFrame so repeated events produce one layout read per frame.
       The slot stays put while the stage is fixed, so it owns the threshold in both directions. */
    useEffect(() => {
        const slot = slotRef.current
        if (!slot || !dock) return

        const apply = (on) => {
            if (on === dockedRef.current) return
            if (on) {
                // MEASURE FIRST. Once the docked class is on, the stage is position:fixed, the
                // slot has no in-flow child left and its offsetHeight is already 0 — so
                // measuring afterwards pins the slot at 0px, it can never scroll back into
                // view, and the player never undocks.
                slot.style.height = `${slot.offsetHeight}px`
                window.clearTimeout(returnTimerRef.current)
                setReturning(false)
            } else {
                setReturning(true)
                window.clearTimeout(returnTimerRef.current)
                returnTimerRef.current = window.setTimeout(() => setReturning(false), 480)
                slot.style.height = ""
            }
            dockedRef.current = on
            setDocked(on)
        }

        let frame = 0
        const update = () => {
            frame = 0
            const el = videoRef.current
            const r = slot.getBoundingClientRect()
            const viewportTopPastMidpoint = r.top <= -r.height * 0.5

            if (!viewportTopPastMidpoint) {
                refused.current = false
                apply(false)
            } else if (el && !el.paused && !refused.current) {
                apply(true)
            }
        }
        const queueUpdate = () => {
            if (frame) return
            frame = window.requestAnimationFrame(update)
        }

        window.addEventListener("scroll", queueUpdate, { passive: true })
        window.addEventListener("resize", queueUpdate)
        videoRef.current?.addEventListener("play", queueUpdate)
        queueUpdate()

        return () => {
            window.removeEventListener("scroll", queueUpdate)
            window.removeEventListener("resize", queueUpdate)
            videoRef.current?.removeEventListener("play", queueUpdate)
            if (frame) window.cancelAnimationFrame(frame)
            window.clearTimeout(returnTimerRef.current)
            slot.style.height = ""
        }
    }, [dock, src, aspect])

    // Turning the dock control off, or swapping the clip, must not strand a fixed mini player.
    useEffect(() => {
        if (!dock && docked) {
            if (slotRef.current) slotRef.current.style.height = ""
            dockedRef.current = false
            setReturning(false)
            setDocked(false)
        }
    }, [dock, docked])

    // The native attribute handles the normal path. Calling play as well covers Framer canvas
    // remounts and source swaps, where the element can be created before the new source settles.
    useEffect(() => {
        const el = videoRef.current
        if (!el || !src || !autoPlay) return
        el.muted = true
        setMuted(true)
        el.play().catch(() => {})
    }, [autoPlay, src])

    const play = () => videoRef.current?.play().catch(() => {})

    const unmute = () => {
        if (videoRef.current) videoRef.current.muted = false
        setMuted(false)
    }

    const undock = () => {
        if (slotRef.current) slotRef.current.style.height = ""
        window.clearTimeout(returnTimerRef.current)
        dockedRef.current = false
        setReturning(false)
        setDocked(false)
    }

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

    const seek = (clientX, track) => {
        const el = videoRef.current
        if (!el || !el.duration) return
        const r = track.getBoundingClientRect()
        el.currentTime = Math.min(1, Math.max(0, (clientX - r.left) / r.width)) * el.duration
    }

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

    const classes = [
        "fc-video",
        playing ? "is-playing" : "",
        muted ? "is-muted" : "",
        docked ? "is-docked" : "",
        returning ? "is-returning" : "",
    ]
        .filter(Boolean)
        .join(" ")

    return (
        <div className={scope} style={style} ref={rootRef}>
            <style>{css(scope, { background, ink, accent, dockCorner })}</style>
            <section
                className={classes}
                data-aspect={aspect}
                aria-labelledby={`${scope}-heading`}
            >
                {heading || copy || eyebrow ? (
                    <div className="fc-video-copy">
                        {eyebrow ? <span className="fc-video-eyebrow">{eyebrow}</span> : null}
                        {heading ? (
                            <h2 className="fc-video-heading" id={`${scope}-heading`}>
                                {heading}
                            </h2>
                        ) : null}
                        {copy ? <p className="fc-video-lede">{copy}</p> : null}
                    </div>
                ) : null}

                {/* The slot holds the space in the page. The stage is what actually moves:
                    when it docks, the stage goes position:fixed and the slot keeps the layout
                    from jumping by the height of a video. */}
                <div className="fc-video-slot" ref={slotRef}>
                    <div className="fc-video-stage">
                        <video
                            className="fc-video-el"
                            ref={videoRef}
                            src={src}
                            poster={poster?.src || undefined}
                            playsInline
                            muted={muted}
                            autoPlay={autoPlay}
                            loop={loop}
                            preload="metadata"
                            onPlay={() => setPlaying(true)}
                            onPause={() => {
                                setPlaying(false)
                                undock() // a paused video has no reason to follow anyone down the page
                            }}
                            onEnded={() => {
                                setPlaying(false)
                                undock()
                            }}
                            onTimeUpdate={onTime}
                            onLoadedMetadata={() => setRemaining(videoRef.current?.duration || 0)}
                        />

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

                        <button
                            className="fc-video-unmute"
                            type="button"
                            aria-label={unmuteLabel || "Unmute video"}
                            onClick={unmute}
                        >
                            <Icon name="sound" />
                            {unmuteLabel ? (
                                <span className="fc-video-unmute-label">{unmuteLabel}</span>
                            ) : null}
                        </button>

                        <div className="fc-video-bar">
                            <button
                                className="fc-video-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-video-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 = Math.min(
                                            el.duration || 0,
                                            Math.max(0, el.currentTime + step)
                                        )
                                }}
                            >
                                <span
                                    className="fc-video-fill"
                                    style={{ width: `${progress}%` }}
                                />
                            </div>

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

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

                        {/* Top-right chip, deliberately. See the header note — the bottom of
                            the frame stays clear for any burned-in subtitles or disclaimer. */}
                        <button
                            className="fc-video-dock-close"
                            type="button"
                            aria-label="Close mini player"
                            onClick={() => {
                                refused.current = true
                                videoRef.current?.pause()
                                undock()
                            }}
                        >
                            <Icon name="close" />
                        </button>
                    </div>
                </div>
            </section>
        </div>
    )
}

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" />
                </>
            ) : (
                <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, dockCorner }) {
    const s = `.${scope}`
    const side = dockCorner === "left" ? "left" : "right"
    const off = dockCorner === "left" ? "right" : "left"
    return `
/* A shrink-to-fit parent makes \`width: 100%\` compute to 0, which collapses every container
   query to its narrowest step. The min-width is the floor; Framer overrides it on canvas. */
${s} { container: fc-video-shell / inline-size; width: 100%; min-width: 320px; }
${s} .fc-video {
  --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-video / 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;
}
${s} .fc-video *, ${s} .fc-video *::before, ${s} .fc-video *::after { box-sizing: border-box; }

${s} .fc-video-copy { max-width: 60ch; margin: 0 0 clamp(20px, 2.6cqi, 32px); }
${s} .fc-video-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-video-heading {
  margin: 0;
  font-size: clamp(24px, 3.2cqi, 40px);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -.035em;
}
${s} .fc-video-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. \`--dock-w\` is the docked width for that shape. */
${s} .fc-video[data-aspect="wide"]   { --ar: 16 / 9; --stage-max: none;  --dock-w: min(340px, 42vw); }
${s} .fc-video[data-aspect="square"] { --ar: 1 / 1;  --stage-max: 520px; --dock-w: min(240px, 34vw); }
${s} .fc-video[data-aspect="tall"]   { --ar: 9 / 16; --stage-max: 420px; --dock-w: min(190px, 30vw); }

/* The slot holds the page's place. Only the stage ever moves. */
${s} .fc-video-slot { position: relative; width: 100%; max-width: var(--stage-max, none); }
${s} .fc-video-stage {
  position: relative;
  width: 100%;
  aspect-ratio: var(--ar, 16 / 9);
  overflow: hidden;
  border-radius: 16px;
  background: ${ink};
  box-shadow: 0 18px 44px color-mix(in srgb, ${ink} 22%, transparent);
  transition: border-radius .32s cubic-bezier(.2, .7, .2, 1);
}
${s} .fc-video-el { display: block; width: 100%; height: 100%; object-fit: cover; }

/* Poster overlay. Disappears the moment playback starts, and is not focusable after that. */
${s} .fc-video-poster {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  border: 0;
  background: linear-gradient(180deg,
    color-mix(in srgb, ${ink} 18%, transparent),
    color-mix(in srgb, ${ink} 54%, transparent));
  color: #fff;
  cursor: pointer;
  font: inherit;
  transition: opacity .3s ease;
}
${s} .fc-video.is-playing .fc-video-poster { opacity: 0; pointer-events: none; }
${s} .fc-video-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-video-bigplay svg { width: 44%; height: auto; }
${s} .fc-video-poster:hover .fc-video-bigplay { transform: scale(1.06); }
${s} .fc-video-caption { font-size: clamp(12px, 1.2cqi, 14px); font-weight: 500; letter-spacing: -.01em; }

/* Prominent sound invitation for muted autoplay. It becomes icon-only in the thumbnail so it
   remains easy to hit without hiding the picture. */
${s} .fc-video-unmute {
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 20px;
  border: 1px solid rgba(255, 255, 255, .34);
  border-radius: 999px;
  background: color-mix(in srgb, ${ink} 76%, transparent);
  color: #fff;
  box-shadow: 0 12px 34px rgba(0, 0, 0, .28);
  backdrop-filter: blur(12px);
  cursor: pointer;
  font: 650 clamp(14px, 1.5cqi, 18px)/1 -apple-system, BlinkMacSystemFont, sans-serif;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, -44%) scale(.97);
  transition: opacity .28s ease, transform .36s cubic-bezier(.2, .7, .2, 1), background .2s ease;
}
${s} .fc-video.is-playing.is-muted .fc-video-unmute {
  opacity: 1;
  pointer-events: auto;
  transform: translate(-50%, -50%) scale(1);
}
${s} .fc-video-unmute:hover { background: color-mix(in srgb, ${ink} 90%, transparent); }
${s} .fc-video-unmute:focus-visible { outline: 3px solid ${accent}; outline-offset: 3px; }
${s} .fc-video-unmute svg { width: 22px; height: 22px; flex: none; }
${s} .fc-video.is-docked .fc-video-unmute {
  width: 48px;
  height: 48px;
  justify-content: center;
  padding: 0;
}
${s} .fc-video.is-docked .fc-video-unmute-label { display: none; }

/* Control bar. Hidden until the video is running, so the poster stays clean. */
${s} .fc-video-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;
  transition: opacity .25s ease;
}
${s} .fc-video.is-playing .fc-video-bar,
${s} .fc-video-stage:hover .fc-video-bar,
${s} .fc-video-stage:focus-within .fc-video-bar { opacity: 1; }

${s} .fc-video-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-video-btn:hover { background: rgba(255, 255, 255, .24); }
${s} .fc-video-btn:focus-visible { outline: 2px solid ${accent}; outline-offset: 2px; }
${s} .fc-video-btn svg { width: 17px; height: 17px; }

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

/* The close chip only belongs to the mini player — inline, there is nothing to close. It sits
   top-right so it never covers any burned-in subtitles or disclaimer along the bottom of the
   frame. */
${s} .fc-video-dock-close {
  display: none;
  position: absolute;
  top: 6px;
  ${side}: 6px;
  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);
}
${s} .fc-video-dock-close:hover { background: color-mix(in srgb, ${ink} 90%, transparent); }
${s} .fc-video-dock-close:focus-visible { outline: 2px solid ${accent}; outline-offset: 2px; }
${s} .fc-video-dock-close svg { width: 14px; height: 14px; }

/* ---------------------------------------------------------------------------
   DOCKED. The stage goes fixed; the slot keeps the section's height so the page
   below does not jump up by the height of a video when it docks.
   --------------------------------------------------------------------------- */
${s} .fc-video.is-docked .fc-video-stage {
  position: fixed;
  z-index: 60;
  ${side}: clamp(12px, 2vw, 24px);
  ${off}: auto;
  bottom: clamp(12px, 2vw, 24px);
  width: var(--dock-w, min(340px, 42vw));
  border-radius: 12px;
  box-shadow: 0 20px 50px rgba(0, 0, 0, .38);
  animation: ${scope}-dock .48s cubic-bezier(.2, .7, .2, 1);
}
${s} .fc-video.is-returning .fc-video-stage {
  animation: ${scope}-return .44s cubic-bezier(.2, .7, .2, 1);
}
${s} .fc-video.is-docked .fc-video-dock-close { display: grid; }
${s} .fc-video.is-docked .fc-video-time { display: none; }
/* Docked, the bar stays out of the way until you reach for it. */
${s} .fc-video.is-docked .fc-video-bar { opacity: 0; }
${s} .fc-video.is-docked .fc-video-stage:hover .fc-video-bar,
${s} .fc-video.is-docked .fc-video-stage:focus-within .fc-video-bar { opacity: 1; }

@keyframes ${scope}-dock {
  from { transform: translateY(10px) scale(.97); opacity: 0; }
  to { transform: none; opacity: 1; }
}
@keyframes ${scope}-return {
  from { transform: translateY(-4px) scale(.985); opacity: 0; }
  to { transform: none; opacity: 1; }
}

/* Mobile. A wide video goes full width along the bottom rather than sitting as a postage
   stamp — but a TALL one must not, or a 9:16 clip at full width is taller than the phone
   screen and covers the page it is supposed to let you keep reading. */
@container fc-video-shell (max-width: 767px) {
  ${s} .fc-video { --fc-gutter: 15px; }
}

@media (max-width: 640px) {
  ${s} .fc-video[data-aspect="wide"].is-docked .fc-video-stage {
    right: 10px;
    left: 10px;
    bottom: 10px;
    width: auto;
  }
  ${s} .fc-video[data-aspect="tall"] { --dock-w: min(150px, 38vw); }
  ${s} .fc-video[data-aspect="square"] { --dock-w: min(180px, 44vw); }
}

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

@media (prefers-reduced-motion: reduce) {
  ${s} .fc-video-stage, ${s} .fc-video-poster, ${s} .fc-video-unmute, ${s} .fc-video-bar, ${s} .fc-video-bigplay { transition-duration: 1ms; }
  ${s} .fc-video.is-docked .fc-video-stage,
  ${s} .fc-video.is-returning .fc-video-stage { animation: none; }
}
`
}

addPropertyControls(VideoFloatDock, {
    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.",
    },
    caption: {
        type: ControlType.String,
        title: "Caption",
        defaultValue: "Presenter name, role",
    },
    unmuteLabel: {
        type: ControlType.String,
        title: "Unmute label",
        defaultValue: "Click to unmute",
    },
    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.",
    },
    dock: {
        type: ControlType.Boolean,
        title: "Dock on scroll",
        defaultValue: true,
        description:
            "Only ever docks while actually playing, and a dismiss is remembered for the page view.",
    },
    autoPlay: {
        type: ControlType.Boolean,
        title: "Autoplay",
        defaultValue: true,
        description: "Starts muted so browser autoplay policies allow playback.",
    },
    loop: {
        type: ControlType.Boolean,
        title: "Loop",
        defaultValue: true,
    },
    dockCorner: {
        type: ControlType.Enum,
        title: "Dock corner",
        options: ["right", "left"],
        optionTitles: ["Bottom right", "Bottom left"],
        defaultValue: "left",
        hidden: (p) => !p.dock,
    },

    background: { type: ControlType.Color, title: "Background", defaultValue: "#FFFFFF" },
    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.