Custom Framer Component · Free, unlicensed
Video Transcript Strip
A self-wiring transcript strip or full transcript list that finds a video on the page by class name and highlights/seeks to the active line.
videotranscriptcaptionsaccessibility
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 |
|---|---|---|
| Video | videoSelector | String |
| Mode | mode | Enum |
| Transcript | transcript | Array |
| Label | label | String |
| Idle text | idleText | String |
| Show times | showTimes | Boolean |
| 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 { useEffect, useId, useMemo, useRef, useState } from "react"
export const META = {
slug: "video-transcript-strip",
title: "Video Transcript Strip",
description: "A self-wiring transcript strip or full transcript list that finds a video on the page by class name and highlights/seeks to the active line.",
tags: ["video", "transcript", "captions", "accessibility"],
category: "custom-framer",
}
/**
* The caption/transcript strip that follows `VideoScrubToCorner.tsx`.
*
* IT IS A SEPARATE COMPONENT BECAUSE IT SITS SOMEWHERE ELSE ON THE PAGE. A breadcrumb strip or
* sidebar is not inside the hero, so this cannot be a prop of the player — it has to be its own
* thing that Framer can drop in its own frame. `VideoScrubToCorner` already carries its own
* floating captions burned over the picture; those are for the 190px thumbnail, where six
* words is the budget. This is the opposite surface: full sentences, in page type, that stay
* readable while the video is a thumbnail in the corner or filling the screen.
*
* IT FINDS THE VIDEO ITSELF. There is NOTHING to wire up on the canvas: on mount it queries
* the document for `.fc-vs-el` — the `<video>` element `VideoScrubToCorner.tsx` renders — and
* listens to that element directly for `timeupdate`, `play` and `pause`. A class name the
* player already renders needs nothing set at all — no second field to type into both panels
* and get subtly wrong.
*
* It still has to be its own component, because the breadcrumb strip is a different part of
* the page and Framer cannot render one component in two places. But it is not WIRED to
* anything — drop it anywhere on the same page and it attaches.
*
* THE SELECTOR IS A PROP so a page with two players can point each transcript at its own —
* give the second player's frame a class in Framer and target `.that-class .fc-vs-el`. It also
* means this works against a plain `<video>` that is not a VideoScrubToCorner at all.
*
* MOUNT ORDER IS NOT GUARANTEED, so a one-shot `querySelector` is not enough — the transcript
* can mount before the hero below it. A MutationObserver watches the document until the
* element appears, then disconnects. Without that it works on reload and fails on first paint,
* which is the worst kind of bug to be handed.
*
* CLICKING A LINE SEEKS. That is the reason to render the whole transcript rather than just
* the current line — it turns a passive caption into a way to navigate the video, which is
* what makes a two-minute clip skimmable for someone who only wants one part of it.
*
* IT MUST SURVIVE HAVING NO PLAYER. On the canvas, and on any page where the video has not
* loaded yet, no event ever arrives. It renders the transcript cold with nothing highlighted
* rather than collapsing to an empty box — an empty box in Framer reads as broken.
*
* 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`.
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight any-prefer-fixed
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 160
* @framerDisableUnlink
*/
export default function VideoTranscriptStrip(props) {
const {
videoSelector = ".fc-vs-el",
transcript = DEFAULT_TRANSCRIPT,
mode = "strip",
label = "Transcript",
idleText = "Press play to follow along.",
showTimes = true,
background = "transparent",
ink = "#2F3A48",
accent = "#F97316",
style,
} = props
const scope = "fc-" + useId().replace(/[^a-zA-Z0-9]/g, "")
const listRef = useRef<HTMLOListElement | null>(null)
const cueRefs = useRef<(HTMLLIElement | null)[]>([])
// The player this is following. A ref, not state — a seek has to reach it from a click
// handler, and re-rendering because a DOM node was found is pointless work.
const videoRef = useRef<HTMLVideoElement | null>(null)
// True once a player has actually been found and reported a time. Before that there is
// nothing to highlight, and the idle line shows instead of a wrong "now playing" state.
const [live, setLive] = useState(false)
const [time, setTime] = useState(0)
const [playing, setPlaying] = useState(false)
const cues = useMemo(
() =>
(Array.isArray(transcript) ? transcript : [])
.map((cue) => ({
at: Number(cue?.at) || 0,
until: Number(cue?.until) || 0,
text: String(cue?.text || ""),
}))
.filter((cue) => cue.text)
.sort((a, b) => a.at - b.at),
[transcript]
)
/* The active cue is the LAST one whose window contains the playhead. A plain loop — a
transcript is tens of lines, not thousands, and a binary search is one more thing that
can be subtly wrong on a boundary. Overlapping windows resolve to the later line, which
is what a human writing timings expects when they fat-finger an end time. */
const active = useMemo(() => {
if (!live) return -1
let found = -1
for (let i = 0; i < cues.length; i++) {
if (time >= cues[i].at && time < cues[i].until) found = i
}
return found
}, [cues, time, live])
/* FIND THE PLAYER, THEN LISTEN TO IT. Everything this component knows comes off the real
media element, so there is no second source of truth for the playhead and no payload
shape to keep in step across two files. */
useEffect(() => {
const selector = String(videoSelector || "").trim() || ".fc-vs-el"
let el: HTMLVideoElement | null = null
let observer: MutationObserver | null = null
const onTime = () => {
if (!el) return
setLive(true)
setTime(el.currentTime || 0)
}
const onPlay = () => { setLive(true); setPlaying(true) }
const onPause = () => setPlaying(false)
const attach = (node: HTMLVideoElement) => {
el = node
videoRef.current = node
node.addEventListener("timeupdate", onTime)
node.addEventListener("seeking", onTime)
node.addEventListener("loadedmetadata", onTime)
node.addEventListener("play", onPlay)
node.addEventListener("pause", onPause)
// Catch a player that is already running — on a soft navigation this component can
// mount while the clip is mid-sentence, and waiting for the next event would show
// the idle line over a playing video.
if (!node.paused) onPlay()
if (node.currentTime > 0) onTime()
}
const find = () => {
const node = document.querySelector(selector)
if (!(node instanceof HTMLVideoElement)) return false
attach(node)
return true
}
if (!find()) {
// Mount order is not guaranteed — this strip can render before the hero below it.
observer = new MutationObserver(() => {
if (find()) { observer?.disconnect(); observer = null }
})
observer.observe(document.documentElement, { childList: true, subtree: true })
}
return () => {
observer?.disconnect()
if (!el) return
el.removeEventListener("timeupdate", onTime)
el.removeEventListener("seeking", onTime)
el.removeEventListener("loadedmetadata", onTime)
el.removeEventListener("play", onPlay)
el.removeEventListener("pause", onPause)
videoRef.current = null
}
}, [videoSelector])
/* Keep the active line in view, but ONLY inside this box — `scrollIntoView` on its own
scrolls the nearest scrollable ancestor too, which on a page with a sticky hero means
the whole document lurches every few seconds while the video plays. Setting
`scrollTop` on the list touches nothing outside it. */
useEffect(() => {
if (mode !== "list" || active < 0) return
const box = listRef.current
const cue = cueRefs.current[active]
if (!box || !cue) return
const target = cue.offsetTop - box.clientHeight / 2 + cue.offsetHeight / 2
box.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
}, [active, mode])
const seek = (at) => {
const el = videoRef.current
// Light the clicked line immediately. The player's own `timeupdate` confirms a frame
// later, and without this the click reads as dropped on a paused video.
setLive(true)
setTime(at)
if (!el) return
el.currentTime = Math.max(0, at)
el.play().catch(() => {})
}
if (cues.length === 0) return <Empty style={style} />
const current = active >= 0 ? cues[active] : null
return (
<div className={scope} style={style}>
<style>{css(scope, { background, ink, accent, mode })}</style>
<section
className={
"fc-tr" +
(playing ? " is-playing" : "") +
(live ? " is-live" : "")
}
data-mode={mode}
aria-label={label || "Transcript"}
>
<div className="fc-tr-head">
{label ? <span className="fc-tr-label">{label}</span> : null}
<span className="fc-tr-dot" aria-hidden="true" />
</div>
{mode === "strip" ? (
/* STRIP — one line at a time, for a breadcrumb bar. Every cue stays
mounted in the same grid cell so they cross-fade in place; mounting
only the active one makes the bar's height jump on every handoff. */
<div className="fc-tr-strip" aria-live="polite">
<span className={"fc-tr-idle" + (current ? "" : " is-on")}>
{idleText}
</span>
{cues.map((cue, i) => (
<button
className={"fc-tr-line" + (i === active ? " is-on" : "")}
key={i}
type="button"
tabIndex={i === active ? 0 : -1}
aria-hidden={i === active ? undefined : "true"}
onClick={() => seek(cue.at)}
>
{showTimes ? (
<span className="fc-tr-at">{fmt(cue.at)}</span>
) : null}
<span className="fc-tr-text">{cue.text}</span>
</button>
))}
</div>
) : (
<ol className="fc-tr-list" ref={listRef}>
{cues.map((cue, i) => (
<li
key={i}
ref={(el) => { cueRefs.current[i] = el }}
className={
"fc-tr-row" +
(i === active ? " is-on" : "") +
(active >= 0 && i < active ? " is-past" : "")
}
>
<button type="button" onClick={() => seek(cue.at)}>
{showTimes ? (
<span className="fc-tr-at">{fmt(cue.at)}</span>
) : null}
<span className="fc-tr-text">{cue.text}</span>
</button>
</li>
))}
</ol>
)}
</section>
</div>
)
}
/* Full sentences, unlike the player's own floating captions. This surface has page width and
page type, so the six-word thumbnail budget does not apply here. Times are seconds against
the clip, and they are what links a line to the video — not its position in the list. */
const DEFAULT_TRANSCRIPT = [
{ at: 0.4, until: 5, text: "Every project we look at gets the same check, start to finish." },
{ at: 5, until: 10, text: "Some details are easy to miss standing back from it." },
{ at: 10, until: 15.5, text: "We document and mark everything we find along the way." },
{ at: 15.5, until: 21.5, text: "That record is what goes into your written summary." },
{ at: 21.5, until: 29, text: "The first look is free, and there is no obligation to proceed." },
]
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 Empty({ style }) {
return (
<div
style={{
...style,
display: "grid",
placeItems: "center",
minHeight: 80,
background: "#F4F5F6",
color: "#2F3A48",
font: "500 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
textAlign: "center",
padding: 16,
}}
>
Add transcript lines in the properties panel.
</div>
)
}
function css(scope, { background, ink, accent, mode }) {
const s = `.${scope}`
return `
${s} { container: fc-tr-shell / inline-size; width: 100%; min-width: 0; max-width: 100%; }
${s} .fc-tr {
--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-tr / inline-size;
box-sizing: border-box;
width: 100%;
padding: ${mode === "list" ? "clamp(18px, 2.4cqi, 28px)" : "clamp(12px, 1.6cqi, 18px)"} var(--fc-edge);
background: ${background};
color: ${ink};
font-stretch: 100%;
-webkit-font-smoothing: antialiased;
}
${s} .fc-tr *, ${s} .fc-tr *::before, ${s} .fc-tr *::after { box-sizing: border-box; }
${s} .fc-tr-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
${s} .fc-tr-label {
color: color-mix(in srgb, ${ink} 55%, transparent);
font-size: clamp(10px, 1cqi, 12px);
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
}
/* The dot is the only thing that says this strip is LIVE rather than a static block of copy.
Grey when nothing is driving it, accent and breathing while the clip runs. */
${s} .fc-tr-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, ${ink} 22%, transparent);
transition: background .3s ease;
}
${s} .fc-tr.is-live .fc-tr-dot { background: color-mix(in srgb, ${accent} 60%, transparent); }
${s} .fc-tr.is-playing .fc-tr-dot {
background: ${accent};
animation: ${scope}pulse 1.6s ease-in-out infinite;
}
@keyframes ${scope}pulse { 50% { opacity: .35; } }
/* STRIP ---------------------------------------------------------------------------------------
ONE GRID CELL, EVERY LINE IN IT. Cross-fading in place is not just the nicer transition: it
also means the strip's height is the height of the LONGEST line from first render, so the
page below it never moves as the video plays. A strip that mounts one line at a time reflows
the whole document every few seconds, which is unforgivable in a breadcrumb bar. */
${s} .fc-tr-strip { display: grid; min-height: 1.5em; }
${s} .fc-tr-strip > * { grid-area: 1 / 1; }
${s} .fc-tr-line,
${s} .fc-tr-idle {
display: flex;
align-items: baseline;
gap: 10px;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
opacity: 0;
transform: translateY(5px);
pointer-events: none;
transition: opacity .32s ease, transform .4s cubic-bezier(.2, .7, .2, 1);
}
${s} .fc-tr-line {
cursor: pointer;
font-size: clamp(15px, 1.7cqi, 21px);
font-weight: 500;
line-height: 1.4;
letter-spacing: -.015em;
}
${s} .fc-tr-idle {
color: color-mix(in srgb, ${ink} 48%, transparent);
font-size: clamp(14px, 1.5cqi, 18px);
font-weight: 500;
}
${s} .fc-tr-line.is-on,
${s} .fc-tr-idle.is-on { opacity: 1; transform: none; }
${s} .fc-tr-line.is-on { pointer-events: auto; }
${s} .fc-tr-line:focus-visible { outline: 2px solid ${accent}; outline-offset: 4px; border-radius: 6px; }
${s} .fc-tr-at {
flex: none;
color: ${accent};
font-size: clamp(10px, 1cqi, 12px);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: .06em;
}
/* LIST ---------------------------------------------------------------------------------------
The full transcript, scrolled to keep the active line centred. Capped in height so it is a
panel in the page rather than a page of its own. */
${s} .fc-tr-list {
max-height: clamp(180px, 34cqi, 300px);
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
scrollbar-width: thin;
}
${s} .fc-tr-row > button {
display: flex;
align-items: baseline;
gap: 12px;
width: 100%;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: none;
color: color-mix(in srgb, ${ink} 46%, transparent);
cursor: pointer;
font: inherit;
font-size: clamp(14px, 1.5cqi, 18px);
font-weight: 500;
line-height: 1.45;
letter-spacing: -.015em;
text-align: left;
transition: color .25s ease, background .25s ease;
}
${s} .fc-tr-row > button:hover { background: color-mix(in srgb, ${ink} 6%, transparent); color: ${ink}; }
${s} .fc-tr-row > button:focus-visible { outline: 2px solid ${accent}; outline-offset: -2px; }
/* Read lines stay darker than unread ones — the transcript shows progress as well as position. */
${s} .fc-tr-row.is-past > button { color: color-mix(in srgb, ${ink} 68%, transparent); }
${s} .fc-tr-row.is-on > button {
background: color-mix(in srgb, ${accent} 14%, transparent);
color: ${ink};
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
${s} .fc-tr-line, ${s} .fc-tr-idle { transition-duration: 1ms; transform: none; }
${s} .fc-tr.is-playing .fc-tr-dot { animation: none; }
}
`
}
addPropertyControls(VideoTranscriptStrip, {
videoSelector: {
type: ControlType.String,
title: "Video",
defaultValue: ".fc-vs-el",
description:
"Leave this alone unless there are two players on the page. It finds the video component anywhere on the page by itself — nothing to match up. For a second player, give its Framer frame a class and use '.that-class .fc-vs-el'.",
},
mode: {
type: ControlType.Enum,
title: "Mode",
options: ["strip", "list"],
optionTitles: ["Strip — one line", "List — full transcript"],
defaultValue: "strip",
description:
"Strip is the breadcrumb bar: the current line only, cross-fading in place. List is the whole transcript, scrolled to keep the current line centred.",
},
transcript: {
type: ControlType.Array,
title: "Transcript",
control: {
type: ControlType.Object,
controls: {
text: { type: ControlType.String, title: "Text", displayTextArea: true, defaultValue: "" },
at: { type: ControlType.Number, title: "From", defaultValue: 0, min: 0, max: 3600, step: 0.5, unit: "s" },
until: { type: ControlType.Number, title: "To", defaultValue: 4, min: 0, max: 3600, step: 0.5, unit: "s" },
},
},
defaultValue: DEFAULT_TRANSCRIPT,
description:
"Seconds against the clip. Full sentences are fine here — this is page type, not the six-word budget the thumbnail captions work to.",
},
label: { type: ControlType.String, title: "Label", defaultValue: "Transcript" },
idleText: {
type: ControlType.String,
title: "Idle text",
defaultValue: "Press play to follow along.",
description: "Shows before the video has reported anything. Leave blank for an empty strip.",
},
showTimes: { type: ControlType.Boolean, title: "Show times", defaultValue: true },
background: { type: ControlType.Color, title: "Background", defaultValue: "transparent" },
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.