Video-Section mit Kapiteln
Eine Section rund um ein Video mit gezeichnetem Poster, Kapiteln und mitlaufendem Transkript. Läuft auch als Vorschau ohne Datei.
Ein Video, das man überfliegen kann
Eine Video-Section rahmt ein Video so, dass man vor dem Abspielen sieht, was darin steckt. Kapitel und Transkript folgen der Zeit, jedes Kapitel und jede Zeile springt zu ihrem Moment, und mit gezeichnetem Poster läuft die Section schon als Vorschau, bevor die Datei da ist.
„Mit Kapiteln“ stellt den Player neben eine Kapitelliste, die der Zeit folgt, jedes Kapitel springt zu seinem Anfang. „Mit Transkript“ klappt unter dem Player ein Transkript auf, die gesprochene Zeile leuchtet auf und jede Zeile springt zu ihrer Zeit. „Groß inszeniert“ setzt einen großen Player mittig in weiches Licht, mit Fakten und einer Aktion darunter. „Text neben Video“ stellt Text und Punkte neben den Player, die Kapitel als Knöpfe, die der Zeit folgen.
Gib jedem Video über zwei Minuten Kapitel, Zuschauer springen zu dem Teil, für den sie gekommen sind. Ein Transkript macht den Inhalt durchsuchbar und barrierefrei. Nimm „Groß inszeniert“ für einen Produktfilm und „Text neben Video“ für Erklärvideos auf einer Landingpage.
Variants
Details
Was Video-Section mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Content
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/video-section.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 12 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle VideoSectionProps aus components/ui/video-section.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Video-Section selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 12 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| play | Englisch Play video | Deutsch Video abspielen |
| pause | Englisch Pause | Deutsch Pausieren |
| resume | Englisch Play | Deutsch Abspielen |
| position | Englisch Position | Deutsch Position |
| chapters | Englisch Chapters | Deutsch Kapitel |
| transcript | Englisch Transcript | Deutsch Transkript |
| showTranscript | Englisch Show transcript | Deutsch Transkript zeigen |
| hideTranscript | Englisch Hide transcript | Deutsch Transkript ausblenden |
| jump | Englisch Jump to {time} | Deutsch Zu {time} springen |
| minutes | Englisch {n} min | Deutsch {n} Min. |
| of | Englisch of | Deutsch von |
| now | Englisch Now playing | Deutsch Läuft gerade |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-controls, aria-current, aria-expanded, aria-label und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 509 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Video-Section?
Mit aiw add @ai-website/video-section. Der Befehl legt die Datei components/ui/video-section.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, section, reveal, motion und button. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Video-Section?
4 Varianten, Mit Kapiteln, Mit Transkript, Groß inszeniert und Text neben Video. Du wählst sie mit der Prop variant, zum Beispiel variant="transcript".
Kann ich die Texte von Video-Section übersetzen?
Ja. Video-Section bringt 12 eigene Texte auf Deutsch und Englisch mit, etwa „Video abspielen“, „Pausieren“ und „Abspielen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Video-Section einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Video-Section mit aiw_components und installieren sie mit aiw_add @ai-website/video-section. Nenn im Prompt die Variante, etwa variant="chapters", dann schreibt der Assistent deine Inhalte direkt in die Props.