Zum Inhalt springen
UI Kit
Accent

Interaktive Produktdemo

Eine Funktion groß und in Aktion: eine Suche, die sich selbst tippt und eingrenzt, dieselbe Ansicht ohne und mit der Funktion, Schritte, die beim Scrollen eine stehende Ansicht verändern, und echte Regler neben einer Telefonvorschau.

4 VariantsEine von 2 Components in Features

Ein Feature, bei der Arbeit gezeigt

Ein Feature im Rampenlicht zeigt eine Funktion bei der Arbeit, statt sie in einem Satz zu beschreiben. Besucher sehen das Problem vor ihren Augen verschwinden, das erklärt den Wert schneller als jede Liste von Vorteilen.

Die „Live-Vorführung“ tippt im Blick selbst eine Suche, die Liste grenzt ein und hebt die Treffer hervor, der erste Treffer gleitet auf, die Schritte daneben gehen mit und ein Knopf hält an und spielt weiter. „Ohne und mit“ wechselt dieselbe Ansicht mit einem Umschalter und gleitender Markierung zwischen einer langen, ungeordneten Liste und der aufgeräumten, gruppierten Fassung. „Schritte beim Scrollen“ hält die Ansicht am Desktop fest, während die Schritte vorbeiziehen und sie verändern, auf Telefon und Tablet bringt jeder Schritt seine eigene Ansicht mit. „Zum Ausprobieren“ stellt echte Regler, einen Schieberegler, eine Kanalwahl und einen Schalter, neben ein Telefon, das die Nachricht zeigt, wie sie ankäme.

Stell das eine Feature ins Licht, wegen dem Menschen wechseln, nicht die längste Liste. Halte die Vorführung kurz, drei oder vier Schritte erzählen die Geschichte. Lass Besucher selbst übernehmen, Ausprobieren überzeugt mehr als Zuschauen.

Variants

Live-VorführungNeu in 1.2

Im Blick tippt sich eine Suche selbst, die Liste grenzt ein und hebt die Treffer hervor, der erste Treffer gleitet auf. Die Schritte daneben gehen mit, ein Knopf hält an und spielt weiter.

Ohne und mitNeu in 1.2

Ein Umschalter mit gleitender Markierung wechselt dieselbe Ansicht zwischen einer langen, ungeordneten Liste und der aufgeräumten, gruppierten Fassung. Beim Ankommen zeigt er einmal das Vorher und schaltet dann selbst um.

Schritte beim ScrollenNeu in 1.2

Auf dem Desktop bleibt die Ansicht stehen, während die Schritte vorbeiziehen, jeder Schritt verändert sie, und oben füllt sich eine Leiste. Auf Telefon und Tablet bringt jeder Schritt seine eigene Ansicht mit.

Zum AusprobierenNeu in 1.2

Echte Regler, ein Schieberegler, eine Kanalwahl und ein Schalter, neben einem Telefon, das die Nachricht zeigt, wie sie ankäme. Jede Änderung lässt die Mitteilung neu hereinfallen.

Details

Was Feature im Rampenlicht mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Features
Bereich
Marketing
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/feature-spotlight.tsx
Bringt mit
utilskitmotionbuttoniconsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 19 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle FeatureSpotlightProps aus components/ui/feature-spotlight.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FeatureSpotlightVariant

    live-demobefore-afterscroll-stepsplayground

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • pointsoptional
    string[]

    Short benefits under the text

  • appoptional
    string

    Name in the window bar of the drawn screen

  • stepsoptional
    SpotlightStep[]

    live-demo, scroll-steps

  • queryoptional
    string

    live-demo: what gets typed and the rows it searches

  • rowsoptional
    SpotlightRow[]
  • beforeoptional
    { label: string; rows: SpotlightRow[] }

    before-after: the screen without the feature, and with it in groups

  • afteroptional
    { label: string; groups: { label: string; rows: SpotlightRow[] }[] }
  • playgroundoptional
    { name: string; time: string; message: string; channels: string[]; min: number; max: number; value: number }

    playground: the message with {name}, {hours} and {time}, and the settings

  • ctaoptional
    { label: string; href: string }

Eingebaute Texte

Diese Texte schreibt Feature im Rampenlicht selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 19 Texte anzeigen
playEnglisch Play the demoDeutsch Demo abspielen
pauseEnglisch Pause the demoDeutsch Demo anhalten
replayEnglisch Play againDeutsch Noch einmal
searchEnglisch SearchDeutsch Suchen
resultsEnglisch {n} resultsDeutsch {n} Treffer
withoutEnglisch WithoutDeutsch Ohne
withEnglisch WithDeutsch Mit
compareEnglisch Show the screenDeutsch Ansicht wählen
hoursEnglisch Send the reminderDeutsch Erinnerung senden
hoursValueEnglisch {n} hours beforeDeutsch {n} Stunden vorher
hourValueEnglisch 1 hour beforeDeutsch 1 Stunde vorher
channelEnglisch ChannelDeutsch Kanal
confirmEnglisch Ask to confirmDeutsch Bestätigung erfragen
confirmHintEnglisch Adds a button to confirm or move the appointmentDeutsch Fügt einen Knopf zum Bestätigen oder Verschieben hinzu
previewEnglisch Preview on the phoneDeutsch Vorschau auf dem Telefon
nowEnglisch nowDeutsch jetzt
yesEnglisch ConfirmDeutsch Bestätigen
moveEnglisch MoveDeutsch Verschieben
stepEnglisch Step {n} of {t}Deutsch Schritt {n} von {t}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Feature im Rampenlicht reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-current, aria-describedby, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup und switch.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 596 Zeilen
components/ui/feature-spotlight.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatTime, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * One feature set large, shown working instead of described. The product
 * screen is drawn from your props, so it shows your own words. Four layouts:
 *
 *   live-demo     a search types itself, results narrow and light up, steps follow along
 *   before-after  the same screen without and with the feature, switched by a toggle
 *   scroll-steps  the screen stays in place while the steps scroll past and change it
 *   playground    real controls beside a phone preview that changes as you set them
 */

export type FeatureSpotlightVariant = 'live-demo' | 'before-after' | 'scroll-steps' | 'playground';

export interface SpotlightRow {
  title: string;
  meta?: string;
  tag?: string;
  /** live-demo: part of the result for the query */
  match?: boolean;
  done?: boolean;
}

export interface SpotlightStep {
  title: string;
  text?: string;
  icon?: IconName;
  /** scroll-steps: what the screen shows during this step */
  rows?: SpotlightRow[];
}

export interface FeatureSpotlightProps {
  variant?: FeatureSpotlightVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Short benefits under the text */
  points?: string[];
  /** Name in the window bar of the drawn screen */
  app?: string;
  /** live-demo, scroll-steps */
  steps?: SpotlightStep[];
  /** live-demo: what gets typed and the rows it searches */
  query?: string;
  rows?: SpotlightRow[];
  /** before-after: the screen without the feature, and with it in groups */
  before?: { label: string; rows: SpotlightRow[] };
  after?: { label: string; groups: { label: string; rows: SpotlightRow[] }[] };
  /** playground: the message with {name}, {hours} and {time}, and the settings */
  playground?: { name: string; time: string; message: string; channels: string[]; min: number; max: number; value: number };
  cta?: { label: string; href: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    play: 'Play the demo',
    pause: 'Pause the demo',
    replay: 'Play again',
    search: 'Search',
    results: '{n} results',
    without: 'Without',
    with: 'With',
    compare: 'Show the screen',
    hours: 'Send the reminder',
    hoursValue: '{n} hours before',
    hourValue: '1 hour before',
    channel: 'Channel',
    confirm: 'Ask to confirm',
    confirmHint: 'Adds a button to confirm or move the appointment',
    preview: 'Preview on the phone',
    now: 'now',
    yes: 'Confirm',
    move: 'Move',
    step: 'Step {n} of {t}',
  },
  de: {
    play: 'Demo abspielen',
    pause: 'Demo anhalten',
    replay: 'Noch einmal',
    search: 'Suchen',
    results: '{n} Treffer',
    without: 'Ohne',
    with: 'Mit',
    compare: 'Ansicht wählen',
    hours: 'Erinnerung senden',
    hoursValue: '{n} Stunden vorher',
    hourValue: '1 Stunde vorher',
    channel: 'Kanal',
    confirm: 'Bestätigung erfragen',
    confirmHint: 'Fügt einen Knopf zum Bestätigen oder Verschieben hinzu',
    preview: 'Vorschau auf dem Telefon',
    now: 'jetzt',
    yes: 'Bestätigen',
    move: 'Verschieben',
    step: 'Schritt {n} von {t}',
  },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = FeatureSpotlightProps & { t: Texts };

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;

function Head({ eyebrow, title, description, points, className }: Pick<FeatureSpotlightProps, 'eyebrow' | 'title' | 'description' | 'points'> & { className?: string }) {
  return (
    <div className={cn('max-w-[34rem]', className)}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className="kit-rise mt-5 text-pretty text-lead text-muted-foreground" style={delay(80)}>
          {description}
        </p>
      )}
      {points?.length ? (
        <ul className="mt-7 grid gap-3">
          {points.map((x, i) => (
            <li key={i} className="kit-rise flex gap-3 text-[15px] text-foreground" style={delay(140 + i * 60)}>
              <Icon name="check" size={17} strokeWidth={2} className="mt-0.5 shrink-0 text-primary" />
              <span className="text-pretty">{x}</span>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}

function Cta({ cta, className }: { cta?: FeatureSpotlightProps['cta']; className?: string }) {
  if (!cta) return null;
  return (
    <Magnetic className={cn('max-sm:flex max-sm:w-full', className)}>
      <Button href={cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
        {cta.label}
      </Button>
    </Magnetic>
  );
}

/** The drawn product window, its content comes from the variant */
function Window({ app, children, className, bar }: { app?: string; children: React.ReactNode; className?: string; bar?: React.ReactNode }) {
  return (
    <div className={cn('relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]', className)}>
      <div className="flex h-10 items-center gap-3 border-b border-border bg-surface px-4">
        <span aria-hidden className="flex gap-1.5">
          <span className="size-2.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]" />
          <span className="size-2.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]" />
          <span className="size-2.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]" />
        </span>
        {app && <span className="truncate text-[12.5px] font-medium text-muted-foreground">{app}</span>}
        <span className="ml-auto">{bar}</span>
      </div>
      {children}
    </div>
  );
}

function Row({ r, lit, dim, check = true, className }: { r: SpotlightRow; lit?: boolean; dim?: boolean; check?: boolean; className?: string }) {
  return (
    <div className={cn('flex items-center gap-3 rounded-lg px-3 py-2.5 transition-[background-color,opacity,transform] duration-500 ease-[var(--ease-kit)]', lit && 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]', dim && 'opacity-30', className)}>
      {check && (
        <span className={cn('grid size-5 shrink-0 place-items-center rounded-md border transition-colors duration-300', r.done ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
          {r.done && <Icon name="check" size={12} strokeWidth={2.6} />}
        </span>
      )}
      <span className="min-w-0 flex-1">
        <span className={cn('block text-pretty text-[14px] font-medium leading-snug', r.done ? 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_30%,transparent)]' : 'text-foreground')}>{r.title}</span>
        {r.meta && <span className="block text-pretty text-[12px] text-muted-foreground">{r.meta}</span>}
      </span>
      {r.tag && <span className="shrink-0 rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-2 py-0.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{r.tag}</span>}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* live-demo                                                           */
/* ------------------------------------------------------------------ */

function LiveDemo(p: ViewProps) {
  const { t } = p;
  const query = p.query ?? '';
  const rows = p.rows ?? [];
  const steps = p.steps ?? [];
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
  // the script: 0 idle, 1 typing, 2 narrowed, 3 opened, then rest. Server and no-JS show the final frame.
  const [phase, setPhase] = useState(3);
  const [typed, setTyped] = useState(query.length);
  const [playing, setPlaying] = useState(true);
  const [run, setRun] = useState(0);
  const timers = useRef<number[]>([]);
  useEffect(() => {
    timers.current.forEach((x) => window.clearTimeout(x));
    timers.current = [];
    if (!inView || !playing || prefersReducedMotion()) return;
    const at = (ms: number, fn: () => void) => timers.current.push(window.setTimeout(fn, ms));
    setPhase(0);
    setTyped(0);
    at(500, () => setPhase(1));
    for (let i = 1; i <= query.length; i++) at(500 + i * 85, () => setTyped(i));
    const typedAt = 500 + query.length * 85;
    at(typedAt + 400, () => setPhase(2));
    at(typedAt + 2000, () => setPhase(3));
    at(typedAt + 5200, () => setRun((x) => x + 1));
    return () => timers.current.forEach((x) => window.clearTimeout(x));
  }, [inView, playing, run, query]);
  const active = Math.min(steps.length - 1, Math.max(0, phase - 1));
  const narrowed = phase >= 2;
  const hits = rows.filter((r) => r.match).length;
  const opened = rows.findIndex((r) => r.match);
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] items-center gap-12 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
        <div>
          <Head {...p} />
          {steps.length > 0 && (
            <ol className="mt-9 grid gap-1">
              {steps.map((s, i) => (
                <li key={i} aria-current={active === i ? 'step' : undefined} className={cn('relative flex gap-3.5 rounded-xl p-3 transition-[background-color,opacity] duration-500', active === i ? 'bg-surface' : 'opacity-60')}>
                  <span className={cn('grid size-8 shrink-0 place-items-center rounded-full border transition-colors duration-500', active === i ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground')}>
                    <Icon name={s.icon ?? 'circle'} size={15} />
                  </span>
                  <span className="min-w-0 pt-1">
                    <span className="block text-[15px] font-semibold text-foreground">{s.title}</span>
                    {s.text && <span className="mt-0.5 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</span>}
                  </span>
                  {active === i && playing && (
                    <span aria-hidden className="absolute inset-x-3 bottom-0 h-px overflow-hidden">
                      <span key={`${run}-${phase}`} className="block h-full origin-left animate-kit-progress bg-primary [--kit-duration:1.6s] motion-reduce:hidden" />
                    </span>
                  )}
                </li>
              ))}
            </ol>
          )}
          <Cta cta={p.cta} className="mt-9" />
        </div>
        <div ref={ref} className="kit-rise relative" style={delay(160)}>
          <div aria-hidden className="pointer-events-none absolute -inset-y-10 inset-x-0 -z-10 bg-[radial-gradient(50%_50%_at_50%_50%,var(--glow),transparent_70%)] xl:-inset-x-10" />
          <Window
            app={p.app}
            bar={
              <button
                type="button"
                onClick={() => (playing ? setPlaying(false) : (setPlaying(true), setRun((x) => x + 1)))}
                aria-label={playing ? t.pause : t.play}
                className="relative grid size-8 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary"
              >
                <Icon name={playing ? 'pause' : 'play'} size={14} />
              </button>
            }
          >
            <div className="grid grid-cols-[minmax(0,1fr)] sm:grid-cols-[minmax(0,1fr)_minmax(0,13rem)] lg:grid-cols-[minmax(0,1fr)] xl:grid-cols-[minmax(0,1fr)_minmax(0,13rem)]">
            <div className="min-w-0 p-4 sm:p-5">
              <div className={cn('flex h-11 items-center gap-2.5 rounded-xl border bg-background px-3.5 transition-[border-color,box-shadow] duration-300', phase === 1 ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] shadow-[0_0_0_4px_var(--glow)]' : 'border-border')}>
                <Icon name="search" size={17} className="shrink-0 text-muted-foreground" />
                <span className="min-w-0 truncate text-[14.5px] text-foreground" aria-hidden>
                  {query.slice(0, typed)}
                  {phase === 1 && <span className="ml-px inline-block h-4 w-px translate-y-0.5 animate-kit-pulse bg-foreground [animation-duration:1s]" />}
                </span>
                <span className="sr-only">
                  {t.search}: {query}
                </span>
                {narrowed && <span className="kit-rise ml-auto shrink-0 text-[12px] text-muted-foreground">{fill(t.results, { n: hits })}</span>}
              </div>
              <div className="mt-3 grid grid-cols-[minmax(0,1fr)] gap-0.5">
                {rows.map((r, i) => (
                  <Row key={i} r={r} check={false} lit={narrowed && r.match} dim={narrowed && !r.match} className={cn(phase === 3 && i === opened && 'ring-1 ring-inset ring-[color-mix(in_oklab,var(--primary)_40%,transparent)]')} />
                ))}
              </div>
            </div>
            {/* the opened result fills the preview pane beside the list */}
            <div className="border-border p-5 max-sm:hidden sm:border-l lg:max-xl:hidden">
              <div className={cn('transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', phase === 3 && opened >= 0 ? 'visible translate-x-0 opacity-100' : 'invisible translate-x-3 opacity-0')}>
                {opened >= 0 && (
                  <>
                    {rows[opened].tag && <span className="rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-2 py-0.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{rows[opened].tag}</span>}
                    <p className="mt-3 text-pretty text-[16px] font-semibold leading-snug text-foreground">{rows[opened].title}</p>
                    {rows[opened].meta && <p className="mt-1 text-[13px] text-muted-foreground">{rows[opened].meta}</p>}
                  </>
                )}
              </div>
              <div aria-hidden className="mt-5 grid gap-2">
                <span className="h-2 w-full rounded-full bg-surface-2" />
                <span className="h-2 w-5/6 rounded-full bg-surface-2" />
                <span className="h-2 w-4/6 rounded-full bg-surface-2" />
                <span className="mt-3 h-2 w-full rounded-full bg-surface-2" />
                <span className="h-2 w-3/6 rounded-full bg-surface-2" />
              </div>
            </div>
            </div>
          </Window>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* before-after                                                        */
/* ------------------------------------------------------------------ */

function BeforeAfter(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const [withIt, setWithIt] = useState(true);
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -25% 0px');
  // shows the "without" state first once in view, then switches on its own
  const [auto, setAuto] = useState(false);
  useEffect(() => {
    if (!inView || auto || prefersReducedMotion()) return;
    setAuto(true);
    setWithIt(false);
    const x = window.setTimeout(() => setWithIt(true), 1800);
    return () => window.clearTimeout(x);
  }, [inView, auto]);
  const before = p.before ?? { label: t.without, rows: [] };
  const after = p.after ?? { label: t.with, groups: [] };
  return (
    <Section background="surface" className={p.className}>
      <Head {...p} className="mx-auto max-w-[42rem] text-center [&_ul]:justify-items-center" />
      <div ref={ref} className="mx-auto mt-12 max-w-[52rem]">
        <div className="flex justify-center">
          <div role="radiogroup" aria-labelledby={`${id}-l`} className="relative inline-grid grid-cols-2 rounded-xl border border-border bg-card p-1 shadow-[var(--shadow-sm)]">
            <span id={`${id}-l`} className="sr-only">
              {t.compare}
            </span>
            <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-lg bg-primary shadow-[0_0_0_3px_var(--glow)] transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: withIt ? 'translateX(100%)' : 'translateX(0)' }} />
            {[false, true].map((v) => (
              <button
                key={String(v)}
                type="button"
                role="radio"
                aria-checked={withIt === v}
                onClick={() => setWithIt(v)}
                onKeyDown={(e) => {
                  if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
                    e.preventDefault();
                    setWithIt(!withIt);
                    (e.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[withIt ? 0 : 1] as HTMLElement | undefined)?.focus();
                  }
                }}
                tabIndex={withIt === v ? 0 : -1}
                className={cn('relative z-[1] h-11 min-w-[8rem] cursor-pointer rounded-lg px-5 text-[14px] font-semibold transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary', withIt === v ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground')}
              >
                {v ? after.label : before.label}
              </button>
            ))}
          </div>
        </div>
        <Window app={p.app} className="mt-8">
          <div className="relative grid min-h-[22rem] grid-cols-[minmax(0,1fr)] [&>*]:[grid-area:1/1]">
            {/* without: one long, unsorted list */}
            <div className={cn('p-4 transition-[opacity,transform,filter,visibility] duration-500 ease-[var(--ease-kit)] sm:p-5', withIt ? 'invisible scale-[0.98] opacity-0 blur-[2px]' : 'visible opacity-100')} aria-hidden={withIt}>
              {before.rows.map((r, i) => (
                <Row key={i} r={r} className="border-b border-border last:border-b-0" />
              ))}
            </div>
            {/* with: the same things, grouped and done where they can be */}
            <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-4 p-4 transition-[opacity,transform,visibility] duration-500 ease-[var(--ease-kit)] sm:grid-cols-2 sm:p-5', withIt ? 'visible opacity-100' : 'invisible translate-y-2 opacity-0')} aria-hidden={!withIt}>
              {after.groups.map((g, gi) => (
                <div key={gi} className="rounded-xl border border-border bg-background p-2" style={{ transitionDelay: `${gi * 80}ms` }}>
                  <p className="flex items-center justify-between px-2 pb-1 pt-1.5 text-[12px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
                    {g.label}
                    <span className="rounded-full bg-surface px-1.5 tabular-nums tracking-normal">{g.rows.length}</span>
                  </p>
                  {g.rows.map((r, i) => (
                    <Row key={i} r={r} />
                  ))}
                </div>
              ))}
            </div>
          </div>
        </Window>
        <div className="mt-8 flex justify-center">
          <Cta cta={p.cta} />
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* scroll-steps                                                        */
/* ------------------------------------------------------------------ */

function StepBlock({ s, i, n, onActive, t, app }: { s: SpotlightStep; i: number; n: number; onActive: (i: number) => void; t: Texts; app?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => e.isIntersecting && onActive(i), { rootMargin: '-45% 0px -45% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, [i, onActive]);
  return (
    <div ref={ref} className="flex min-h-0 flex-col justify-center py-8 lg:min-h-[70vh] lg:py-0">
      <p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{fill(t.step, { n: i + 1, t: n })}</p>
      <h3 className="mt-2 flex items-center gap-3 text-balance text-[clamp(1.375rem,1.2rem+0.8vw,1.75rem)] font-semibold leading-tight tracking-[-0.02em] text-foreground">
        {s.icon && <Icon name={s.icon} size={22} className="shrink-0 text-primary" />}
        {s.title}
      </h3>
      {s.text && <p className="mt-3 max-w-[30rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
      {/* on phones and tablets each step brings its own screen */}
      <Window app={app} className="mt-6 lg:hidden">
        <div className="grid grid-cols-[minmax(0,1fr)] gap-0.5 p-3">
          {(s.rows ?? []).map((r, j) => (
            <Row key={j} r={r} />
          ))}
        </div>
      </Window>
    </div>
  );
}

function ScrollSteps(p: ViewProps) {
  const { t } = p;
  const steps = p.steps ?? [];
  const [active, setActive] = useState(0);
  const set = useRef((i: number) => setActive(i)).current;
  const rows = steps[active]?.rows ?? [];
  return (
    <Section className={p.className}>
      <Head {...p} />
      <div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16">
        <div>
          {steps.map((s, i) => (
            <StepBlock key={i} s={s} i={i} n={steps.length} onActive={set} t={t} app={p.app} />
          ))}
        </div>
        <div className="relative max-lg:hidden">
          <div className="sticky top-[calc(50vh-13rem)]">
            <Window app={p.app}>
              <div className="flex gap-1.5 border-b border-border px-4 py-3" aria-hidden>
                {steps.map((_, i) => (
                  <span key={i} className={cn('h-1 flex-1 rounded-full transition-colors duration-500', i <= active ? 'bg-primary' : 'bg-track')} />
                ))}
              </div>
              <div aria-live="polite" className="min-h-[17rem] p-4">
                <div key={active} className="grid grid-cols-[minmax(0,1fr)] gap-0.5">
                  {rows.map((r, j) => (
                    <Row key={j} r={r} className="kit-rise" />
                  ))}
                </div>
              </div>
            </Window>
          </div>
        </div>
      </div>
      <Cta cta={p.cta} className="mt-10" />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* playground                                                          */
/* ------------------------------------------------------------------ */

function Playground(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const id = useId();
  const pg = p.playground ?? { name: 'Alex', time: '09:30', message: '{name}, {time}', channels: ['SMS'], min: 1, max: 48, value: 24 };
  const [hours, setHours] = useState(pg.value);
  const [channel, setChannel] = useState(0);
  const [confirm, setConfirm] = useState(true);
  const [bump, setBump] = useState(0);
  const text = fill(pg.message, { name: pg.name, hours, time: formatTime(pg.time, locale) });
  const pct = Math.round(((hours - pg.min) / Math.max(1, pg.max - pg.min)) * 100);
  const change = (fn: () => void) => {
    fn();
    setBump((x) => x + 1);
  };
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] items-center gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-20">
        <div>
          <Head {...p} />
          <div className="kit-rise mt-9 grid gap-6 rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)]" style={delay(160)}>
            <div>
              <div className="flex items-baseline justify-between gap-3">
                <label htmlFor={`${id}-h`} className="text-[14px] font-medium text-foreground">
                  {t.hours}
                </label>
                <output htmlFor={`${id}-h`} className="text-[14px] font-semibold tabular-nums text-foreground">
                  {hours === 1 ? t.hourValue : fill(t.hoursValue, { n: hours })}
                </output>
              </div>
              <div className="relative mt-3 h-11">
                <span aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-track" />
                <span aria-hidden className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
                <input
                  id={`${id}-h`}
                  type="range"
                  min={pg.min}
                  max={pg.max}
                  value={hours}
                  onChange={(e) => change(() => setHours(Number(e.target.value)))}
                  className="relative h-11 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-card [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:shadow-[var(--shadow-md)] focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_5px_var(--glow)]"
                />
              </div>
            </div>
            <fieldset className="min-w-0">
              <legend className="text-[14px] font-medium text-foreground">{t.channel}</legend>
              <div className="mt-3 grid grid-cols-3 gap-2">
                {pg.channels.map((c, i) => (
                  <label key={c} className={cn('relative flex h-11 cursor-pointer items-center justify-center rounded-lg border text-[14px] font-medium transition-[background-color,border-color,color] duration-200 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-primary', channel === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
                    <input type="radio" name={`${id}-c`} checked={channel === i} onChange={() => change(() => setChannel(i))} className="sr-only" />
                    {c}
                  </label>
                ))}
              </div>
            </fieldset>
            <div className="flex items-start justify-between gap-4">
              <span>
                <span id={`${id}-cl`} className="block text-[14px] font-medium text-foreground">
                  {t.confirm}
                </span>
                <span id={`${id}-ch`} className="mt-0.5 block text-pretty text-[13px] text-muted-foreground">
                  {t.confirmHint}
                </span>
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={confirm}
                aria-labelledby={`${id}-cl`}
                aria-describedby={`${id}-ch`}
                onClick={() => change(() => setConfirm(!confirm))}
                className="relative grid h-11 w-14 shrink-0 cursor-pointer place-items-center rounded-full focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary"
              >
                <span className={cn('relative h-6 w-11 rounded-full transition-colors duration-300', confirm ? 'bg-primary' : 'bg-track')}>
                  <span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-card shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', confirm && 'translate-x-5')} />
                </span>
              </button>
            </div>
          </div>
          <Cta cta={p.cta} className="mt-8" />
        </div>
        {/* the phone preview */}
        <figure className="kit-rise mx-auto w-full max-w-[18rem]" style={delay(220)}>
          <figcaption className="sr-only">{t.preview}</figcaption>
          <div className="dark relative rounded-[2.6rem] border border-border bg-bezel p-2.5 shadow-[var(--shadow-lg)]">
            <div className="relative overflow-hidden rounded-[2.1rem] flex aspect-[9/18] flex-col bg-[radial-gradient(120%_80%_at_30%_0%,color-mix(in_oklab,var(--primary)_35%,var(--background)),var(--background))] px-3.5 pb-2.5 pt-14">
              <span aria-hidden className="absolute left-1/2 top-2.5 h-6 w-24 -translate-x-1/2 rounded-full bg-black" />
              <p aria-hidden className="text-center text-[44px] font-semibold leading-none tracking-[-0.03em] text-foreground">{formatTime(pg.time, locale).replace(/\s?[AP]M$/, '')}</p>
              <p aria-hidden className="mt-1 text-center text-[12px] text-muted-foreground">{hours === 1 ? t.hourValue : fill(t.hoursValue, { n: hours })}</p>
              <div key={bump} aria-live="polite" className="mt-8 animate-[fs-in_.45s_var(--ease-kit)] rounded-2xl bg-[color-mix(in_oklab,var(--card)_86%,transparent)] p-3.5 text-foreground shadow-[var(--shadow-md)] backdrop-blur motion-reduce:animate-none">
                <p className="flex items-center gap-2 text-[11.5px] text-muted-foreground">
                  <span className="grid size-5 place-items-center rounded-md bg-primary text-primary-foreground">
                    <Icon name={channel === 0 ? 'chat' : channel === 1 ? 'mail' : 'bell'} size={11} />
                  </span>
                  <span className="font-semibold uppercase tracking-[0.06em]">{pg.channels[channel]}</span>
                  <span className="ml-auto">{t.now}</span>
                </p>
                <p className="mt-2 text-pretty text-[13.5px] leading-snug">{text}</p>
                {confirm && (
                  <div className="mt-3 grid grid-cols-2 gap-1.5">
                    <span className="rounded-lg bg-primary py-1.5 text-center text-[12.5px] font-semibold text-primary-foreground">{t.yes}</span>
                    <span className="rounded-lg bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] py-1.5 text-center text-[12.5px] font-medium">{t.move}</span>
                  </div>
                )}
              </div>
              {/* the home indicator at the foot of the screen */}
              <span aria-hidden className="mx-auto mt-auto h-1 w-24 shrink-0 rounded-full bg-[color-mix(in_oklab,var(--foreground)_55%,transparent)]" />
            </div>
          </div>
          <style>{'@keyframes fs-in{from{opacity:.4;transform:translateY(-8px) scale(.98)}}'}</style>
        </figure>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */

const VIEWS: Record<FeatureSpotlightVariant, (p: ViewProps) => React.ReactNode> = {
  'live-demo': LiveDemo,
  'before-after': BeforeAfter,
  'scroll-steps': ScrollSteps,
  playground: Playground,
};

export function FeatureSpotlight({ variant = 'live-demo', labels, ...rest }: FeatureSpotlightProps) {
  const t = useTexts(TEXTS, labels);
  const View = VIEWS[variant] ?? LiveDemo;
  return <View {...rest} variant={variant} t={t} />;
}

Häufige Fragen

Wie installiere ich Feature im Rampenlicht?

Mit aiw add @ai-website/feature-spotlight. Der Befehl legt die Datei components/ui/feature-spotlight.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion, button, icon und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Feature im Rampenlicht?

4 Varianten, Live-Vorführung, Ohne und mit, Schritte beim Scrollen und Zum Ausprobieren. Du wählst sie mit der Prop variant, zum Beispiel variant="before-after".

Kann ich die Texte von Feature im Rampenlicht übersetzen?

Ja. Feature im Rampenlicht bringt 19 eigene Texte auf Deutsch und Englisch mit, etwa „Demo abspielen“, „Demo anhalten“ und „Noch einmal“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Feature im Rampenlicht einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Feature im Rampenlicht mit aiw_components und installieren sie mit aiw_add @ai-website/feature-spotlight. Nenn im Prompt die Variante, etwa variant="live-demo", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert