Skip to content
UI Kit
Accent color

Interactive product demo

One feature set large and shown working: a search that types itself and narrows, the same screen without and with the feature, steps that change a sticky screen while scrolling and real controls beside a phone preview.

4 variantsOne of 2 components in Features

One feature, shown working

A feature spotlight shows one feature at work instead of describing it in a sentence. Visitors see the problem disappear in front of them, which explains the value faster than any list of benefits.

Live demo types a query by itself once in view, the list narrows and lights the hits and the first result slides open, the steps beside it follow along and a button pauses and resumes. Without and with switches the same screen between a long unsorted list and the tidy, grouped version with a toggle and a sliding marker. Steps while scrolling keeps the screen in place on desktops while the steps scroll past and change it, on phones and tablets every step brings its own screen. Playground puts real controls, a slider, a channel choice and a switch, beside a phone that shows the message as it would arrive, every change drops the notification in again.

Spotlight the one feature that makes people switch, not the longest list. Keep the demo short, three or four steps tell the story. Let visitors take over in the playground, trying it themselves convinces more than watching.

Variants

Live demoNew in 1.2

Once in view a query types itself, the list narrows and lights the hits, the first result slides open. The steps beside it follow along, a button pauses and resumes.

Without and withNew in 1.2

A toggle with a sliding marker switches the same screen between a long unsorted list and the tidy, grouped version. It shows the before once on arrival, then switches by itself.

Steps while scrollingNew in 1.2

On desktops the screen stays in place while the steps scroll past, each step changes it and a bar on top fills. On phones and tablets every step brings its own screen.

PlaygroundNew in 1.2

Real controls, a slider, a channel choice and a switch, beside a phone that shows the message as it would arrive. Every change drops the notification in again.

Details

What Feature spotlight brings along, read from the registry and the source.

Category
Features
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/feature-spotlight.tsx
Brings along
utilskitmotionbuttoniconsection
npm packages
None
Languages
English and German, 19 texts of its own
Studio
Adjust in code

Props

The FeatureSpotlightProps interface from components/ui/feature-spotlight.tsx, with the comments from the code.

  • variantoptional
    FeatureSpotlightVariant

    live-demobefore-afterscroll-stepsplayground

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    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 }

Built-in texts

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

Show all 19 texts
playEnglish Play the demoGerman Demo abspielen
pauseEnglish Pause the demoGerman Demo anhalten
replayEnglish Play againGerman Noch einmal
searchEnglish SearchGerman Suchen
resultsEnglish {n} resultsGerman {n} Treffer
withoutEnglish WithoutGerman Ohne
withEnglish WithGerman Mit
compareEnglish Show the screenGerman Ansicht wählen
hoursEnglish Send the reminderGerman Erinnerung senden
hoursValueEnglish {n} hours beforeGerman {n} Stunden vorher
hourValueEnglish 1 hour beforeGerman 1 Stunde vorher
channelEnglish ChannelGerman Kanal
confirmEnglish Ask to confirmGerman Bestätigung erfragen
confirmHintEnglish Adds a button to confirm or move the appointmentGerman Fügt einen Knopf zum Bestätigen oder Verschieben hinzu
previewEnglish Preview on the phoneGerman Vorschau auf dem Telefon
nowEnglish nowGerman jetzt
yesEnglish ConfirmGerman Bestätigen
moveEnglish MoveGerman Verschieben
stepEnglish Step {n} of {t}German Schritt {n} von {t}

Accessibility

Read from the source of the component.

  • Feature spotlight handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-current, aria-describedby, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
  • Uses the roles radio, radiogroup and switch.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • 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. 3 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, 596 lines
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} />;
}

Frequent questions

How do I install Feature spotlight?

With aiw add @ai-website/feature-spotlight. The command puts components/ui/feature-spotlight.tsx into your project, the code is yours from then on. It brings along motion, button, icon and section. It needs no further npm packages.

Which variants does Feature spotlight have?

4 variants, Live demo, Without and with, Steps while scrolling and Playground. You pick one with the variant prop, for example variant="before-after".

Can I translate the texts of Feature spotlight?

Yes. Feature spotlight carries 19 texts of its own in English and German, such as “Play the demo”, “Pause the demo” and “Play again”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Feature spotlight?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Feature spotlight with aiw_components and install it with aiw_add @ai-website/feature-spotlight. Name the variant in your prompt, for example variant="live-demo", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard