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