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.
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. In Framer: Assets → Code → Create Code File.
- 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.