Zum Inhalt springen
UI Kit
Accent

Video-Section mit Kapiteln

Eine Section rund um ein Video mit gezeichnetem Poster, Kapiteln und mitlaufendem Transkript. Läuft auch als Vorschau ohne Datei.

4 VariantsEine von 3 Components in Content

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

Mit KapitelnNeu in 1.2

Der Player neben einer Kapitelliste, die der Zeit folgt, jedes Kapitel springt an seinen Anfang.

Mit TranskriptNeu in 1.2

Ein Transkript zum Ausklappen unter dem Player, die gesprochene Zeile leuchtet auf und jede springt zu ihrer Zeit.

Groß inszeniertNeu in 1.2

Ein großer, mittiger Player in weichem Licht, darunter Fakten und eine Aktion.

Text neben VideoNeu in 1.2

Text und Punkte neben dem Player, Kapitel als Knöpfe, die der Zeit folgen.

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
Bringt mit
utilskiticonsectionrevealmotionbutton
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 12 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle VideoSectionProps aus components/ui/video-section.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    VideoSectionVariant

    chapterstranscriptfeaturesplit

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • posteroptional
    React.ReactNode

    The poster, e.g. a drawn scene or an <img>

  • srcoptional
    string

    Video file. Without it the section runs as a preview.

  • durationoptional
    number

    Length in seconds

  • videoTitleoptional
    string

    Name of the video, for the player's label

  • chaptersoptional
    VideoChapter[]
  • transcriptoptional
    TranscriptLine[]
  • pointsoptional
    string[]

    split: points beside the player

  • factsoptional
    { value: string; label: string }[]

    feature: facts under the player

  • actionoptional
    { label: string; href: string }
  • defaultOpenoptional
    boolean

    transcript: 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
playEnglisch Play videoDeutsch Video abspielen
pauseEnglisch PauseDeutsch Pausieren
resumeEnglisch PlayDeutsch Abspielen
positionEnglisch PositionDeutsch Position
chaptersEnglisch ChaptersDeutsch Kapitel
transcriptEnglisch TranscriptDeutsch Transkript
showTranscriptEnglisch Show transcriptDeutsch Transkript zeigen
hideTranscriptEnglisch Hide transcriptDeutsch Transkript ausblenden
jumpEnglisch Jump to {time}Deutsch Zu {time} springen
minutesEnglisch {n} minDeutsch {n} Min.
ofEnglisch ofDeutsch von
nowEnglisch Now playingDeutsch 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
components/ui/video-section.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert