Skip to content
UI Kit
Accent color

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.

4 variantsOne of 3 components in Content

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.

Variants

With chaptersNew in 1.2

The player beside a chapter list that follows the time, each chapter jumps to its start.

With transcriptNew in 1.2

A transcript that unfolds under the player, the spoken line lights up and every line jumps to its time.

FeatureNew in 1.2

A large centered player in a soft glow, with facts and one action below.

Text beside videoNew in 1.2

Text and points beside the player, chapters as buttons that follow the time.

Details

What Video section brings along, read from the registry and the source.

Category
Content
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/video-section.tsx
Brings along
utilskiticonsectionrevealmotionbutton
npm packages
None
Languages
English and German, 12 texts of its own
Studio
Adjust in code

Props

The VideoSectionProps interface from components/ui/video-section.tsx, with the comments from the code.

  • 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

Built-in texts

Video section writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 12 texts
playEnglish Play videoGerman Video abspielen
pauseEnglish PauseGerman Pausieren
resumeEnglish PlayGerman Abspielen
positionEnglish PositionGerman Position
chaptersEnglish ChaptersGerman Kapitel
transcriptEnglish TranscriptGerman Transkript
showTranscriptEnglish Show transcriptGerman Transkript zeigen
hideTranscriptEnglish Hide transcriptGerman Transkript ausblenden
jumpEnglish Jump to {time}German Zu {time} springen
minutesEnglish {n} minGerman {n} Min.
ofEnglish ofGerman von
nowEnglish Now playingGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard