Video section with chapters
A section around one video with drawn poster, chapters and a transcript that follows. Works as a preview without a file.
A video people can scan
A video section frames one video so people see what is in it before they press play. Chapters and a transcript follow the time, every chapter and every line jumps to its moment, and with a drawn poster the section already works as a preview before the file is there.
With chapters puts the player beside a chapter list that follows the time, each chapter jumps to its start. With transcript unfolds a transcript under the player, the spoken line lights up and every line jumps to its time. Feature centers a large player in a soft glow, with facts and one action below. Text beside video sets text and points beside the player, with chapters as buttons that follow the time.
Give every video over two minutes chapters, viewers jump to the part they came for. A transcript makes the content searchable and accessible. Use the feature version for a product film and text beside video for explainers on a landing page.
Details
What Video section brings along, read from the registry and the source.
- Category
- Content
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/video-section.tsx
- npm packages
- None
- Languages
- English and German, 12 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The VideoSectionProps interface from components/ui/video-section.tsx, with the comments from the code.
variantoptionalVideoSectionVariantchapterstranscriptfeaturesplit
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeposteroptionalReact.ReactNodeThe poster, e.g. a drawn scene or an <img>
srcoptionalstringVideo file. Without it the section runs as a preview.
durationoptionalnumberLength in seconds
videoTitleoptionalstringName of the video, for the player's label
chaptersoptionalVideoChapter[]transcriptoptionalTranscriptLine[]pointsoptionalstring[]split: points beside the player
factsoptional{ value: string; label: string }[]feature: facts under the player
actionoptional{ label: string; href: string }defaultOpenoptionalbooleantranscript: open from the start
Built-in texts
Video section writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 12 texts
| Key | English | German |
|---|---|---|
| play | English Play video | German Video abspielen |
| pause | English Pause | German Pausieren |
| resume | English Play | German Abspielen |
| position | English Position | German Position |
| chapters | English Chapters | German Kapitel |
| transcript | English Transcript | German Transkript |
| showTranscript | English Show transcript | German Transkript zeigen |
| hideTranscript | English Hide transcript | German Transkript ausblenden |
| jump | English Jump to {time} | German Zu {time} springen |
| minutes | English {n} min | German {n} Min. |
| of | English of | German von |
| now | English Now playing | German Läuft gerade |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-current, aria-expanded, aria-label and aria-valuetext, so screen readers announce purpose and state.
- Uses the role group.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 509 lines
'use client';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
/*
* A section around one video: poster, play, chapters and the transcript.
* With `src` it plays the file. Without it the section runs as a preview,
* the poster stays and time moves on, so chapters and transcript can be
* tried on any page.
*
* chapters the player beside a chapter list that follows the time
* transcript the player above a transcript that unfolds, the line
* being spoken lights up, every line jumps to its time
* feature a large centered player with facts and an action below
* split text and points beside the player, chapters as marks
*/
export type VideoSectionVariant = 'chapters' | 'transcript' | 'feature' | 'split';
export interface VideoChapter {
/** Start in seconds */
time: number;
title: string;
text?: string;
}
export interface TranscriptLine {
time: number;
text: string;
speaker?: string;
}
const TEXTS = {
en: {
play: 'Play video',
pause: 'Pause',
resume: 'Play',
position: 'Position',
chapters: 'Chapters',
transcript: 'Transcript',
showTranscript: 'Show transcript',
hideTranscript: 'Hide transcript',
jump: 'Jump to {time}',
minutes: '{n} min',
of: 'of',
now: 'Now playing',
},
de: {
play: 'Video abspielen',
pause: 'Pausieren',
resume: 'Abspielen',
position: 'Position',
chapters: 'Kapitel',
transcript: 'Transkript',
showTranscript: 'Transkript zeigen',
hideTranscript: 'Transkript ausblenden',
jump: 'Zu {time} springen',
minutes: '{n} Min.',
of: 'von',
now: 'Läuft gerade',
},
};
type Texts = (typeof TEXTS)['en'];
export interface VideoSectionProps {
variant?: VideoSectionVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** The poster, e.g. a drawn scene or an <img> */
poster?: React.ReactNode;
/** Video file. Without it the section runs as a preview. */
src?: string;
/** Length in seconds */
duration?: number;
/** Name of the video, for the player's label */
videoTitle?: string;
chapters?: VideoChapter[];
transcript?: TranscriptLine[];
/** split: points beside the player */
points?: string[];
/** feature: facts under the player */
facts?: { value: string; label: string }[];
action?: { label: string; href: string };
/** transcript: open from the start */
defaultOpen?: boolean;
labels?: Partial<Texts>;
className?: string;
}
const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
/** 65 -> 1:05 */
export const clock = (s: number) => {
const v = Math.max(0, Math.floor(s));
const h = Math.floor(v / 3600);
const m = Math.floor((v % 3600) / 60);
const ss = String(v % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
};
/* ------------------------------------------------------------------ */
/* Playback */
/* ------------------------------------------------------------------ */
function usePlayback(duration: number, src?: string) {
const video = useRef<HTMLVideoElement>(null);
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(false);
const [started, setStarted] = useState(false);
const [length, setLength] = useState(duration);
// preview: time moves on by itself while playing
useEffect(() => {
if (src || !playing) return;
let last = performance.now();
let frame = requestAnimationFrame(function tick(now) {
const dt = (now - last) / 1000;
last = now;
setTime((t) => Math.min(length, t + dt));
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [playing, src, length]);
useEffect(() => {
if (!src && playing && time >= length) setPlaying(false);
}, [time, playing, src, length]);
// pause when the tab hides
useEffect(() => {
const onHide = () => document.hidden && setPlaying(false);
document.addEventListener('visibilitychange', onHide);
return () => document.removeEventListener('visibilitychange', onHide);
}, []);
useEffect(() => {
const v = video.current;
if (!v) return;
if (playing) v.play().catch(() => setPlaying(false));
else v.pause();
}, [playing]);
const toggle = useCallback(() => {
setStarted(true);
setPlaying((p) => {
if (!p) setTime((t) => (t >= length - 0.05 ? 0 : t));
return !p;
});
}, [length]);
const seek = useCallback(
(t: number, play = true) => {
const n = Math.min(Math.max(0, t), length);
setTime(n);
if (video.current) video.current.currentTime = n;
setStarted(true);
if (play) setPlaying(true);
},
[length],
);
const videoProps = src
? {
ref: video,
src,
playsInline: true,
preload: 'metadata' as const,
onTimeUpdate: (e: React.SyntheticEvent<HTMLVideoElement>) => setTime(e.currentTarget.currentTime),
onLoadedMetadata: (e: React.SyntheticEvent<HTMLVideoElement>) => Number.isFinite(e.currentTarget.duration) && setLength(e.currentTarget.duration),
onEnded: () => setPlaying(false),
}
: null;
return { time, playing, started, length, toggle, seek, videoProps };
}
type Playback = ReturnType<typeof usePlayback>;
const currentIndex = (list: { time: number }[] | undefined, time: number) => {
if (!list?.length) return -1;
let i = 0;
list.forEach((c, j) => {
if (c.time <= time + 0.01) i = j;
});
return i;
};
/* ------------------------------------------------------------------ */
/* Player */
/* ------------------------------------------------------------------ */
function Player({ p, pb, t, className, glow = false }: { p: VideoSectionProps; pb: Playback; t: Texts; className?: string; glow?: boolean }) {
const ci = currentIndex(p.chapters, pb.time);
const chapter = ci >= 0 ? p.chapters?.[ci] : undefined;
const pct = pb.length ? pb.time / pb.length : 0;
const reduce = typeof window !== 'undefined' && prefersReducedMotion();
// the big play button leaves with the first play, its focus moves on to the pause button
const control = useRef<HTMLButtonElement>(null);
const fromBig = useRef(false);
useEffect(() => {
if (pb.started && fromBig.current) {
fromBig.current = false;
control.current?.focus();
}
}, [pb.started]);
return (
<div className={cn('relative', className)}>
{glow && <div aria-hidden className="absolute -inset-x-4 -inset-y-6 -z-10 xl:-inset-x-10 xl:-inset-y-8 rounded-[3rem] bg-[radial-gradient(closest-side,var(--glow),transparent)] blur-2xl" />}
<div role="group" aria-label={p.videoTitle ?? t.play} className="group/player relative aspect-video overflow-hidden rounded-2xl border border-border bg-bezel shadow-[var(--shadow-lg)]">
{/* poster, it drifts very slowly while a preview plays */}
<div data-clip-ok className={cn('absolute inset-0 transition-[transform,filter] ease-linear [&>*]:size-full', pb.playing && !reduce ? 'scale-[1.06] duration-[20s]' : 'scale-100 duration-700', pb.videoProps && pb.started && 'opacity-0')}>
{p.poster}
</div>
{pb.videoProps && <video {...pb.videoProps} className={cn('absolute inset-0 size-full object-cover', !pb.started && 'opacity-0')} aria-label={p.videoTitle} />}
<div aria-hidden className={cn('absolute inset-0 transition-opacity duration-500', pb.started ? 'bg-[linear-gradient(to_top,rgb(0_0_0/0.55),transparent_45%)]' : 'bg-[linear-gradient(to_top,rgb(0_0_0/0.45),rgb(0_0_0/0.08)_55%,transparent)]')} />
{/* chapter name while playing */}
{chapter && pb.started && (
<span key={chapter.title} className="dark absolute left-3 top-3 max-w-[calc(100%-1.5rem)] truncate rounded-md bg-black/55 px-2.5 py-1 text-[12px] font-medium text-white backdrop-blur-md motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)] sm:left-4 sm:top-4">
{chapter.title}
</span>
)}
{/* the big play button before the first play */}
{!pb.started && (
<button type="button" onClick={() => { fromBig.current = true; pb.toggle(); }} aria-label={p.videoTitle ? `${t.play}: ${p.videoTitle}` : t.play} className={cn('absolute inset-0 flex cursor-pointer items-center justify-center', focus, 'focus-visible:-outline-offset-4')}>
<span className="relative flex size-16 items-center justify-center rounded-full bg-white text-black shadow-[0_10px_40px_rgb(0_0_0/0.35)] transition-transform duration-300 ease-[var(--ease-kit)] group-hover/player:scale-110 sm:size-20">
<span aria-hidden className="absolute -inset-2 rounded-full border border-white/60 motion-safe:animate-kit-pulse" />
<Icon name="play" size={26} className="translate-x-0.5" />
</span>
<span className="absolute bottom-4 left-4 right-4 flex items-center justify-between gap-3 text-left text-white sm:bottom-5 sm:left-5 sm:right-5">
<span className="truncate text-[14px] font-medium">{p.videoTitle}</span>
<span className="shrink-0 rounded-md bg-black/55 px-2 py-0.5 text-[12px] font-medium tabular-nums backdrop-blur-md">{clock(pb.length)}</span>
</span>
</button>
)}
{/* controls */}
{pb.started && (
<div className="dark absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 pb-2 pt-6 text-white sm:gap-3 sm:px-4 sm:pb-3">
<button ref={control} type="button" onClick={pb.toggle} aria-label={pb.playing ? t.pause : t.resume} className={cn('flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-white/15', focus)}>
<Icon name={pb.playing ? 'pause' : 'play'} size={18} />
</button>
<div className="relative flex h-11 min-w-0 flex-1 items-center">
<div aria-hidden className="pointer-events-none absolute inset-x-0 h-1 overflow-hidden rounded-full bg-white/25">
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${round(pct, 4)})` }} />
</div>
{p.chapters?.slice(1).map((c) => (
<span key={c.time} aria-hidden className="pointer-events-none absolute h-1 w-0.5 bg-black/50" style={{ left: `${round((c.time / pb.length) * 100, 3)}%` }} />
))}
<input
type="range"
min={0}
max={round(pb.length)}
step={1}
value={round(pb.time)}
aria-label={t.position}
aria-valuetext={`${clock(pb.time)} ${t.of} ${clock(pb.length)}`}
onChange={(e) => pb.seek(Number(e.target.value), pb.playing)}
className="relative h-11 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgb(255_255_255/0.2)] focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_4px_var(--ring)]"
/>
</div>
<span className="shrink-0 text-[12px] font-medium tabular-nums text-white/90 sm:text-[13px]">
{clock(pb.time)} <span className="text-white/60">/ {clock(pb.length)}</span>
</span>
</div>
)}
</div>
</div>
);
}
function Head({ p, center = false, className }: { p: VideoSectionProps; center?: boolean; className?: string }) {
if (!p.title && !p.eyebrow) return null;
return (
<div className={cn('max-w-[44rem]', center && 'mx-auto text-center', className)}>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
{p.description && <p className={cn('mt-5 text-pretty text-lead text-muted-foreground', center && 'mx-auto max-w-[36rem]')}>{p.description}</p>}
</div>
);
}
/* ------------------------------------------------------------------ */
function Chapters({ p, t }: { p: VideoSectionProps; t: Texts }) {
const pb = usePlayback(p.duration ?? 240, p.src);
const chapters = p.chapters ?? [];
const ci = currentIndex(chapters, pb.time);
return (
<>
<Head p={p} className="mb-10 sm:mb-12" />
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-8">
<Reveal effect="scale">
<Player p={p} pb={pb} t={t} />
</Reveal>
<nav aria-label={t.chapters} className="lg:relative">
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:absolute lg:inset-0">
<p className="flex items-center justify-between border-b border-border px-5 py-3.5 text-[14px] font-semibold text-foreground">
{t.chapters}
<span className="text-[13px] font-normal tabular-nums text-muted-foreground">{chapters.length}</span>
</p>
<ol className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto p-2">
{chapters.map((c, i) => {
const on = pb.started && i === ci;
const end = chapters[i + 1]?.time ?? pb.length;
const local = on ? Math.min(1, Math.max(0, (pb.time - c.time) / Math.max(1, end - c.time))) : 0;
return (
<li key={c.time}>
<button
type="button"
onClick={() => pb.seek(c.time)}
aria-current={on ? 'step' : undefined}
aria-label={`${c.title}, ${t.jump.replace('{time}', clock(c.time))}`}
className={cn('relative grid w-full cursor-pointer grid-cols-[3rem_minmax(0,1fr)] gap-3 overflow-hidden rounded-lg px-3 py-3 text-left transition-colors', on ? 'bg-accent' : 'hover:bg-accent/60', focus)}
>
<span className={cn('pt-px text-[12.5px] font-medium tabular-nums', on ? 'text-primary' : 'text-muted-foreground')}>{clock(c.time)}</span>
<span className="min-w-0">
<span className={cn('block text-[14.5px] font-medium leading-snug', on ? 'text-foreground' : 'text-foreground/90')}>{c.title}</span>
{c.text && <span className="mt-1 block text-pretty text-[13px] leading-relaxed text-muted-foreground">{c.text}</span>}
</span>
{on && (
<span aria-hidden className="absolute inset-x-3 bottom-0 h-0.5 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left bg-primary" style={{ transform: `scaleX(${round(local, 3)})` }} />
</span>
)}
</button>
</li>
);
})}
</ol>
</div>
</nav>
</div>
</>
);
}
function Transcript({ p, t }: { p: VideoSectionProps; t: Texts }) {
const pb = usePlayback(p.duration ?? 240, p.src);
const lines = p.transcript ?? [];
const li = currentIndex(lines, pb.time);
const [open, setOpen] = useState(!!p.defaultOpen);
const id = useId();
const box = useRef<HTMLOListElement>(null);
// keep the spoken line in view inside the panel, never move the page
useEffect(() => {
const el = box.current;
const on = el?.querySelector<HTMLElement>('[aria-current="true"]');
if (!el || !on || !open || !pb.started) return;
const top = on.offsetTop - el.clientHeight / 2 + on.clientHeight / 2;
el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [li, open, pb.started]);
return (
<div className="mx-auto max-w-[56rem]">
<Head p={p} className="mb-10 sm:mb-12" />
<Reveal effect="scale">
<Player p={p} pb={pb} t={t} />
</Reveal>
<div className="mt-4 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen((o) => !o)} className={cn('flex min-h-14 w-full cursor-pointer items-center gap-3 px-5 text-left transition-colors hover:bg-surface', focus, 'focus-visible:-outline-offset-2')}>
<Icon name="captions" size={18} className="text-muted-foreground" />
<span className="flex-1 text-[14.5px] font-medium text-foreground">{open ? t.hideTranscript : t.showTranscript}</span>
{pb.started && li >= 0 && !open && <span className="hidden max-w-[50%] truncate text-[13px] text-muted-foreground sm:block">{lines[li]?.text}</span>}
<Icon name="chevron-down" size={18} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
</button>
<div id={id} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className={cn('min-h-0 overflow-hidden', !open && 'invisible')}>
<ol ref={box} aria-label={t.transcript} className="kit-scrollbar relative max-h-80 overflow-y-auto border-t border-border p-2">
{lines.map((l, i) => {
const on = pb.started && i === li;
return (
<li key={l.time} aria-current={on ? 'true' : undefined}>
<button
type="button"
onClick={() => pb.seek(l.time)}
className={cn('grid w-full cursor-pointer grid-cols-[3.25rem_minmax(0,1fr)] gap-3 rounded-lg px-3 py-2.5 text-left transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))]' : 'hover:bg-accent/60', focus, 'focus-visible:-outline-offset-2')}
>
<span className={cn('pt-0.5 text-[12.5px] font-medium tabular-nums', on ? 'text-primary' : 'text-subtle-foreground')}>{clock(l.time)}</span>
<span className={cn('max-w-[40rem] text-pretty text-[15px] leading-relaxed', on ? 'text-foreground' : 'text-muted-foreground')}>
{l.speaker && <span className="font-semibold text-foreground">{l.speaker} </span>}
{l.text}
</span>
</button>
</li>
);
})}
</ol>
</div>
</div>
</div>
</div>
);
}
function Feature({ p, t }: { p: VideoSectionProps; t: Texts }) {
const pb = usePlayback(p.duration ?? 240, p.src);
return (
<>
<Head p={p} center className="mb-12 sm:mb-16" />
<Reveal effect="scale" className="mx-auto max-w-[64rem]">
<Player p={p} pb={pb} t={t} glow />
</Reveal>
{(p.facts?.length || p.action) && (
<div className="mx-auto mt-10 flex max-w-[64rem] flex-col items-center gap-8 lg:flex-row lg:justify-between">
{p.facts?.length ? (
<Stagger as="ul" step={90} className="grid w-full grid-cols-[minmax(0,1fr)] divide-y divide-border sm:w-auto sm:grid-flow-col sm:auto-cols-fr sm:divide-x sm:divide-y-0">
{p.facts.map((f) => (
<li key={f.label} className="flex items-baseline justify-between gap-4 py-3 sm:block sm:px-8 sm:py-0 sm:first:pl-0">
<span className="block text-[clamp(1.5rem,1.3rem+0.8vw,2rem)] font-semibold tabular-nums tracking-[-0.03em] text-foreground">{f.value}</span>
<span className="block text-[14px] text-muted-foreground sm:mt-1">{f.label}</span>
</li>
))}
</Stagger>
) : (
<span />
)}
{p.action && (
<Button href={p.action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{p.action.label}
</Button>
)}
</div>
)}
</>
);
}
function Split({ p, t }: { p: VideoSectionProps; t: Texts }) {
const pb = usePlayback(p.duration ?? 240, p.src);
const chapters = p.chapters ?? [];
const ci = currentIndex(chapters, pb.time);
return (
<div className="grid grid-cols-[minmax(0,1fr)] items-center gap-12 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16">
<div>
<Head p={p} />
{p.points?.length ? (
<Stagger as="ul" step={80} className="mt-8 space-y-3">
{p.points.map((pt) => (
<li key={pt} className="flex gap-3 text-[15.5px] leading-relaxed text-foreground">
<span className="mt-1 flex size-5 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="check" size={12} strokeWidth={2.4} />
</span>
{pt}
</li>
))}
</Stagger>
) : null}
{chapters.length > 0 && (
<div className="mt-8 border-t border-border pt-6">
<p className="text-[13px] font-medium text-subtle-foreground">{t.chapters}</p>
<ol className="mt-3 flex flex-wrap gap-2">
{chapters.map((c, i) => {
const on = pb.started && i === ci;
return (
<li key={c.time}>
<button
type="button"
onClick={() => pb.seek(c.time)}
aria-current={on ? 'step' : undefined}
className={cn('inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 text-[13px] transition-colors sm:min-h-9', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground', focus)}
>
<span className="tabular-nums text-subtle-foreground">{clock(c.time)}</span>
<span className="font-medium">{c.title}</span>
</button>
</li>
);
})}
</ol>
</div>
)}
{p.action && (
<div className="mt-8">
<Button href={p.action.href} variant="outline" icon="arrow-right" className="max-sm:w-full">
{p.action.label}
</Button>
</div>
)}
</div>
<Reveal effect="scale" className="relative">
<Player p={p} pb={pb} t={t} glow />
</Reveal>
</div>
);
}
/**
* A section around one video with a drawn or real poster: chapters beside
* the player, a transcript that follows, a large feature or text beside it.
* <VideoSection variant="chapters" poster={<img … />} duration={252} chapters={[{ time: 0, title: '…' }]} />
*/
export function VideoSection(props: VideoSectionProps) {
const { variant = 'chapters', className, labels } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
{variant === 'transcript' ? <Transcript p={props} t={t} /> : variant === 'feature' ? <Feature p={props} t={t} /> : variant === 'split' ? <Split p={props} t={t} /> : <Chapters p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Video section?
With aiw add @ai-website/video-section. The command puts components/ui/video-section.tsx into your project, the code is yours from then on. It brings along icon, section, reveal, motion and button. It needs no further npm packages.
Which variants does Video section have?
4 variants, With chapters, With transcript, Feature and Text beside video. You pick one with the variant prop, for example variant="transcript".
Can I translate the texts of Video section?
Yes. Video section carries 12 texts of its own in English and German, such as “Play video”, “Pause” and “Play”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Video section?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Video section with aiw_components and install it with aiw_add @ai-website/video-section. Name the variant in your prompt, for example variant="chapters", and the assistant writes your content straight into the props.