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.
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
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 19 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FeatureSpotlightProps aus components/ui/feature-spotlight.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFeatureSpotlightVariantlive-demobefore-afterscroll-stepsplayground
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodepointsoptionalstring[]Short benefits under the text
appoptionalstringName in the window bar of the drawn screen
stepsoptionalSpotlightStep[]live-demo, scroll-steps
queryoptionalstringlive-demo: what gets typed and the rows it searches
rowsoptionalSpotlightRow[]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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| play | Englisch Play the demo | Deutsch Demo abspielen |
| pause | Englisch Pause the demo | Deutsch Demo anhalten |
| replay | Englisch Play again | Deutsch Noch einmal |
| search | Englisch Search | Deutsch Suchen |
| results | Englisch {n} results | Deutsch {n} Treffer |
| without | Englisch Without | Deutsch Ohne |
| with | Englisch With | Deutsch Mit |
| compare | Englisch Show the screen | Deutsch Ansicht wählen |
| hours | Englisch Send the reminder | Deutsch Erinnerung senden |
| hoursValue | Englisch {n} hours before | Deutsch {n} Stunden vorher |
| hourValue | Englisch 1 hour before | Deutsch 1 Stunde vorher |
| channel | Englisch Channel | Deutsch Kanal |
| confirm | Englisch Ask to confirm | Deutsch Bestätigung erfragen |
| confirmHint | Englisch Adds a button to confirm or move the appointment | Deutsch Fügt einen Knopf zum Bestätigen oder Verschieben hinzu |
| preview | Englisch Preview on the phone | Deutsch Vorschau auf dem Telefon |
| now | Englisch now | Deutsch jetzt |
| yes | Englisch Confirm | Deutsch Bestätigen |
| move | Englisch Move | Deutsch Verschieben |
| step | Englisch 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
'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.