docs(examples): make hover-audio accessible, lazy-loaded, and guard-complete

HoverVideo now: (1) exposes a focusable button that toggles/plays sound by
keyboard, touch and pointer — hover is an enhancement, not the only path;
(2) gates the source behind an IntersectionObserver so offscreen cards no longer
download/decode, releasing the buffer on exit; (3) still blocks autoplay under
reduced motion while allowing voluntary playback via the button. Routed all 30
Thirty-Days cards through it (hasAudio={false} for the 12 silent ones) so no raw
<video autoPlay> bypasses the motion guard. Flagged by Magi (P1 #4/#5/#6).
This commit is contained in:
ukimsanov
2026-08-05 05:48:23 -07:00
parent 0f6259d461
commit 0467bc8129
2 changed files with 82 additions and 38 deletions
+70 -26
View File
@@ -1,10 +1,11 @@
export const HoverVideo = ({ src, poster, className }) => {
export const HoverVideo = ({ src, poster, className, hasAudio = true }) => {
const videoRef = useRef(null);
const wrapRef = useRef(null);
const [muted, setMuted] = useState(true);
const [inView, setInView] = useState(false);
// Lazy initializer, not a post-mount effect: with useState(false) the first
// committed render would emit `<video src autoPlay loop>` and only then pull the
// attributes, so a reduce-motion visitor has already started fetching the file.
// Lazy initializer, not a post-mount effect: the preference is known on the
// first committed render, so a reduce-motion visitor never autoplays first.
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
@@ -18,24 +19,44 @@ export const HoverVideo = ({ src, poster, className }) => {
return () => query.removeEventListener("change", onChange);
}, []);
// React can drop src/autoPlay/loop from the DOM, but that neither pauses a
// playing element nor aborts its resource: pause(), removeAttribute("src") and
// load() are all required when the preference flips to reduce mid-session.
// Only assign/decode a source near the viewport. Without this every card on
// the page downloads and decodes its MP4 immediately, on- or off-screen.
useEffect(() => {
if (!reduced) return;
const el = wrapRef.current;
if (!el || typeof IntersectionObserver !== "function") {
setInView(true);
return;
}
const observer = new IntersectionObserver(
(entries) => setInView(entries[0]?.isIntersecting ?? false),
{ rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Release the decoded resource once the card leaves the viewport. React props
// alone neither pause an element nor abort its download, so pause() +
// removeAttribute("src") + load() are all required. This same teardown is what
// stops autoplay when the reduce-motion preference flips on mid-session.
useEffect(() => {
if (inView && !reduced) return;
const video = videoRef.current;
if (!video) return;
video.pause();
video.removeAttribute("src");
video.load();
}, [reduced]);
if (!inView) {
video.removeAttribute("src");
video.load();
setMuted(true);
}
}, [inView, reduced]);
const hear = () => {
if (reduced) return;
const video = videoRef.current;
if (!video) return;
if (!video || !hasAudio) return;
setMuted(false);
video.muted = false;
// Voluntary playback — allowed even under reduced motion (autoplay is not).
video.play().catch(() => {});
};
const silence = () => {
@@ -43,20 +64,43 @@ export const HoverVideo = ({ src, poster, className }) => {
const video = videoRef.current;
if (video) video.muted = true;
};
const toggle = () => (muted ? hear() : silence());
const autoplaying = inView && !reduced;
return (
<video
ref={videoRef}
className={className}
src={reduced ? undefined : src}
poster={poster}
autoPlay={!reduced}
muted={muted}
loop={!reduced}
playsInline
preload="none"
onMouseEnter={hear}
onMouseLeave={silence}
/>
<div ref={wrapRef} className={`relative overflow-hidden ${className ?? ""}`}>
<video
ref={videoRef}
className="absolute inset-0 h-full w-full object-cover"
src={inView ? src : undefined}
poster={poster}
autoPlay={autoplaying}
muted={muted}
loop={autoplaying}
playsInline
preload="none"
onMouseEnter={() => {
if (hasAudio && !reduced) hear();
}}
onMouseLeave={silence}
/>
{hasAudio && (
<button
type="button"
onClick={toggle}
aria-pressed={!muted}
aria-label={muted ? "Play this preview with sound" : "Mute this preview"}
className="absolute left-2 top-2 z-10 inline-flex items-center gap-1 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white backdrop-blur transition hover:bg-black/75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
{muted ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z" /><line x1="23" y1="9" x2="17" y2="15" /><line x1="17" y1="9" x2="23" y2="15" /></svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 5.5a9 9 0 0 1 0 13" /></svg>
)}
{muted ? "Sound" : "On"}
</button>
)}
</div>
);
};
+12 -12
View File
@@ -19,13 +19,13 @@ Days 1 to 9 cover the inputs: a repo, a track, a topic, a speaker, a design file
<CardGroup cols={2}>
<Card title="Day 1 — Install HyperFrames" href="https://x.com/HeyGen/status/2074176916819685648">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-01.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-01.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-01.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-01.jpg" hasAudio={false} />
Give your coding agent the HyperFrames skills. One router reads the request and picks the workflow.
</Card>
<Card title="Day 2 — Give your brand a motion language" href="https://x.com/HeyGen/status/2074574265714905167">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-02.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-02.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-02.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-02.jpg" hasAudio={false} />
Turn a visual identity into reusable motion direction, written down once as `FRAME.md` and read on every later build.
@@ -55,7 +55,7 @@ Days 1 to 9 cover the inputs: a repo, a track, a topic, a speaker, a design file
</Card>
<Card title="Day 7 — Motion graphics" href="https://x.com/HeyGen/status/2076381187640324427">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-07.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-07.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-07.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-07.jpg" hasAudio={false} />
Make a short, design-led animation where the motion carries the message.
@@ -67,7 +67,7 @@ Days 1 to 9 cover the inputs: a repo, a track, a topic, a speaker, a design file
</Card>
<Card title="Day 9 — Figma to HyperFrames" href="https://x.com/HeyGen/status/2077072141309382770">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-09.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-09.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-09.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-09.jpg" hasAudio={false} />
Turn an existing design into a video composition — as live HTML, not a flattened export.
@@ -80,7 +80,7 @@ Days 10 to 19 are about steering: how you ask, what you inspect, and what you fi
<CardGroup cols={2}>
<Card title="Day 10 — Anatomy of a prompt" href="https://x.com/HeyGen/status/2077438104982667282">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-10.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-10.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-10.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-10.jpg" hasAudio={false} />
Structure a request so the agent gets the important intent right the first time.
@@ -98,19 +98,19 @@ Days 10 to 19 are about steering: how you ask, what you inspect, and what you fi
</Card>
<Card title="Day 13 — Studio preview, part 1" href="https://x.com/HeyGen/status/2078589328562016559">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-13.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-13.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-13.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-13.jpg" hasAudio={false} />
Inspect a composition before rendering — in Studio, through your agent, or from the terminal.
</Card>
<Card title="Day 14 — Studio preview, part 2: keyframes" href="https://x.com/HeyGen/status/2078945075787452660">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-14.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-14.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-14.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-14.jpg" hasAudio={false} />
Adjust motion with visible keyframe controls, one lane per animated property.
</Card>
<Card title="Day 15 — Sample the web" href="https://x.com/HeyGen/status/2079251321484722675">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-15.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-15.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-15.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-15.jpg" hasAudio={false} />
Bring useful web motion into a composition by capturing the real page.
@@ -147,13 +147,13 @@ Days 20 to 30 move from one composition to a repeatable pipeline: reusable block
<CardGroup cols={2}>
<Card title="Day 20 — Claude Design" href="https://x.com/HyperFrames_/status/2081184676430160379">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-20.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-20.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-20.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-20.jpg" hasAudio={false} />
Take a design direction into a HyperFrames project without redrawing it by hand.
</Card>
<Card title="Day 21 — Cloud rendering" href="https://x.com/HyperFrames_/status/2081491370485952790">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-21.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-21.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-21.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-21.jpg" hasAudio={false} />
Render without making one local computer the bottleneck. Two commands hand the job to the cloud.
@@ -183,7 +183,7 @@ Days 20 to 30 move from one composition to a repeatable pipeline: reusable block
</Card>
<Card title="Day 26 — Media effects and overlays" href="https://x.com/HyperFrames_/status/2083324288632045910">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-26.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-26.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-26.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-26.jpg" hasAudio={false} />
Add designed treatments to real media. Two to six authored colour ramps change the whole visual language.
@@ -207,7 +207,7 @@ Days 20 to 30 move from one composition to a repeatable pipeline: reusable block
</Card>
<Card title="Day 30 — Thirty for thirty">
<video className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-30.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-30.jpg" autoPlay muted loop playsInline preload="none" />
<HoverVideo className="w-full aspect-video rounded-lg object-cover bg-zinc-100 dark:bg-zinc-900" src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-30.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/thirty-days-day-30.jpg" hasAudio={false} />
Thirty days in one cut — a second from every lesson, back to back. From install on Day 1 to the whole series here.