# Welcome cards

> Three dashboard welcome cards — today at a glance, onboarding checklist and what’s new — with staggered count-ups, one-at-a-time steps and a swipeable release carousel.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/welcome-cards
- Install: `npx shadcn@latest add @geturui/welcome-cards`
- Dependencies: motion, lucide-react

## Variants

### Today

Time-aware greeting, today’s numbers counting up in a stagger and an up-next strip you can step through.

Install: `npx shadcn@latest add @geturui/welcome-cards-today`

```tsx
import { WelcomeTodayCard } from "@/components/examples/welcome-cards/today";

export function Home() {
  return <WelcomeTodayCard name="Maya" hour={9} stats={stats} events={events} onJoin={join} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| name | `string` | "Maya" | First name in the greeting. |
| hour | `number` | 9 | Local hour used to pick the greeting. |
| dateLabel | `string` | — | Formatted date line. |
| stats | `{ label, value, hint? }[]` | — | Today’s numbers. |
| events | `{ title, time, relative, people }[]` | — | Upcoming events. |
| onJoin | `(event) => void` | — | Join button handler. |
| className | `string` | — | Extra classes on the card. |

### Onboarding checklist

One step open at a time; completing a step ticks, fills its segment and advances, then the card celebrates.

Install: `npx shadcn@latest add @geturui/welcome-cards-onboarding`

```tsx
import { OnboardingChecklistCard } from "@/components/examples/welcome-cards/onboarding";

export function GettingStarted() {
  return <OnboardingChecklistCard steps={steps} onStep={(id) => router.push(routes[id])} onDismiss={hide} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| title | `string` | "Get set up" | Card heading. |
| steps | `OnboardingStep[]` | — | { id, title, description, action, done? }. |
| onStep | `(id: string) => void` | — | Called when a step’s action is pressed. |
| onDismiss | `() => void` | — | Hide checklist handler. |
| className | `string` | — | Extra classes on the card. |

### What’s new

Swipeable release carousel with drawn art; a Pro feature turns the button into an ember upgrade.

Install: `npx shadcn@latest add @geturui/welcome-cards-whats-new`

```tsx
import { WhatsNewCard } from "@/components/examples/welcome-cards/whats-new";

export function Release() {
  return <WhatsNewCard version="Version 4.2 · Oct 2" slides={slides} onUpgrade={openCheckout} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| version | `string` | — | Release caption. |
| slides | `WhatsNewSlide[]` | — | { title, body, art?: "branches" | "search" | "audit", pro? }. |
| onTry / onUpgrade / onDismiss | `handlers` | — | Button handlers. |
| className | `string` | — | Extra classes on the card. |

## Accessibility

- The greeting hour and date are props, so server and client render the same text.
- Checklist steps are disclosure buttons with aria-expanded; completed steps announce “done”.
- The release carousel responds to arrow keys and buttons as well as swipe, and reduced motion turns slides into fades.

## Source

### components/examples/welcome-cards/today.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
import { ChevronLeft, ChevronRight, Video } from "lucide-react";
import { cn } from "@/lib/utils";

export interface TodayStat {
  label: string;
  value: number;
  /** Small caption under the number. */
  hint?: string;
}

export interface TodayEvent {
  title: string;
  /** Formatted time, e.g. "10:30". */
  time: string;
  /** Relative caption, e.g. "in 25 min". */
  relative: string;
  people: string[];
}

export interface WelcomeTodayCardProps {
  name?: string;
  /** Local hour (0–23) used for the greeting; pass it in so render stays deterministic. */
  hour?: number;
  /** Formatted date, e.g. "Friday, October 2". */
  dateLabel?: string;
  stats?: TodayStat[];
  events?: TodayEvent[];
  onJoin?: (event: TodayEvent) => void;
  className?: string;
}

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

function greeting(h: number) {
  return h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
}

function CountUp({ to, delay, reduce }: { to: number; delay: number; reduce: boolean }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  React.useEffect(() => {
    const el = ref.current;
    if (!el || reduce) return;
    el.textContent = "0";
    const c = animate(0, to, { duration: 0.9, delay, ease: [0.22, 1, 0.36, 1], onUpdate: (v) => (el.textContent = String(Math.round(v))) });
    return () => c.stop();
  }, [to, delay, reduce]);
  return <span ref={ref}>{to}</span>;
}

/** Dashboard welcome: time-aware greeting, today’s numbers counting up in a stagger, and an up-next strip you can step through. */
export function WelcomeTodayCard({
  name = "Maya",
  hour = 9,
  dateLabel = "Friday, October 2",
  stats = [
    { label: "Meetings", value: 4, hint: "2h 15m total" },
    { label: "Tasks due", value: 6, hint: "2 overdue" },
    { label: "Unread", value: 18, hint: "3 mentions" },
  ],
  events = [
    { title: "Design review: billing v2", time: "10:30", relative: "in 25 min", people: ["PR", "TH", "KM"] },
    { title: "1:1 with Jordan", time: "13:00", relative: "in 3h 55m", people: ["JL"] },
    { title: "Pricing sync", time: "16:00", relative: "in 6h 55m", people: ["AO", "LM"] },
  ],
  onJoin,
  className,
}: WelcomeTodayCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [i, setI] = React.useState(0);
  const [dir, setDir] = React.useState(1);
  const ev = events[i];

  function go(d: number) {
    setDir(d);
    setI((n) => (n + d + events.length) % events.length);
  }

  return (
    <section aria-label="Today" className={cn("@container w-full max-w-[520px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <motion.div initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 260, damping: 28 }}>
        <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-subtle">{dateLabel}</p>
        <h2 className="mt-1.5 font-display text-[26px] font-semibold leading-tight tracking-[-0.6px] text-ink">
          {greeting(hour)}, {name}
        </h2>
        <p className="mt-1 text-sm text-ink-muted">Here is what today looks like.</p>
      </motion.div>

      <dl className="mt-5 grid grid-cols-3 gap-2">
        {stats.map((s, k) => (
          <motion.div
            key={s.label}
            initial={reduce ? false : { opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ type: "spring", stiffness: 280, damping: 26, delay: reduce ? 0 : 0.12 + k * 0.08 }}
            className="rounded-md bg-surface-sunken/70 px-3 py-3"
          >
            <dt className="text-[11.5px] font-medium text-ink-muted">{s.label}</dt>
            <dd className="mt-1 font-display text-[26px] font-semibold leading-none tracking-[-0.6px] text-ink tabular-nums">
              <CountUp to={s.value} delay={0.15 + k * 0.08} reduce={reduce} />
            </dd>
            {s.hint && <dd className="mt-1 truncate text-[11px] text-ink-subtle">{s.hint}</dd>}
          </motion.div>
        ))}
      </dl>

      {ev && (
        <div className="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-border p-3">
          <div className="relative min-w-[200px] flex-1 overflow-hidden" aria-live="polite">
            <AnimatePresence mode="popLayout" initial={false} custom={dir}>
              <motion.div
                key={i}
                custom={dir}
                initial={reduce ? { opacity: 0 } : { opacity: 0, x: dir * 24, filter: "blur(6px)" }}
                animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, x: dir * -24, filter: "blur(6px)" }}
                transition={{ type: "spring", stiffness: 340, damping: 32 }}
              >
                <p className="text-[11px] font-medium text-ink-subtle">
                  Up next · <span className="text-ink">{ev.time}</span> · {ev.relative}
                </p>
                <p className="mt-0.5 truncate text-[14px] font-semibold text-ink">{ev.title}</p>
                <div className="mt-1.5 flex">
                  {ev.people.map((p, k) => (
                    <span key={p} aria-hidden className={cn("grid size-5 place-items-center rounded-full border-2 border-surface-raised bg-surface-sunken font-mono text-[8px] font-semibold text-ink", k && "-ml-1.5")}>
                      {p}
                    </span>
                  ))}
                  <span className="sr-only">{ev.people.length} attendees</span>
                </div>
              </motion.div>
            </AnimatePresence>
          </div>
          <div className="ml-auto flex items-center gap-1.5">
            <button type="button" aria-label="Previous event" onClick={() => go(-1)} className={cn("grid size-8 place-items-center rounded-full text-ink-muted hover:bg-surface-hover hover:text-ink", focus)}>
              <ChevronLeft aria-hidden className="size-4" />
            </button>
            <button type="button" aria-label="Next event" onClick={() => go(1)} className={cn("grid size-8 place-items-center rounded-full text-ink-muted hover:bg-surface-hover hover:text-ink", focus)}>
              <ChevronRight aria-hidden className="size-4" />
            </button>
            <motion.button type="button" whileTap={reduce ? undefined : { scale: 0.96 }} onClick={() => onJoin?.(ev)} className={cn("inline-flex h-9 items-center gap-1.5 rounded-full bg-volt px-4 text-[13px] font-semibold text-on-volt", focus)}>
              <Video aria-hidden className="size-4" />
              Join
            </motion.button>
          </div>
        </div>
      )}
    </section>
  );
}

```

### components/examples/welcome-cards/onboarding.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, PartyPopper } from "lucide-react";
import { cn } from "@/lib/utils";

export interface OnboardingStep {
  id: string;
  title: string;
  description: string;
  /** Button label inside the open step. */
  action: string;
  done?: boolean;
}

export interface OnboardingChecklistCardProps {
  title?: string;
  steps?: OnboardingStep[];
  /** Called when a step’s action is pressed; the step is then marked done. */
  onStep?: (id: string) => void;
  onDismiss?: () => void;
  className?: string;
}

const DEFAULT_STEPS: OnboardingStep[] = [
  { id: "workspace", title: "Create your workspace", description: "Name it and pick a URL your team will recognise.", action: "Open settings", done: true },
  { id: "invite", title: "Invite two teammates", description: "Projects move faster with reviewers. Invites are free on every plan.", action: "Send invites", done: true },
  { id: "import", title: "Import your first project", description: "Bring tasks in from a CSV, Linear or Jira. It takes about a minute.", action: "Import project" },
  { id: "integrate", title: "Connect Slack", description: "Get updates where your team already talks, and reply without switching apps.", action: "Connect Slack" },
  { id: "automate", title: "Turn on a weekly digest", description: "A Monday summary of what shipped and what is at risk.", action: "Turn on digest" },
];

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

/** Getting-started checklist: one step open at a time, completing a step ticks and advances, and the card celebrates when everything is done. */
export function OnboardingChecklistCard({ title = "Get set up", steps = DEFAULT_STEPS, onStep, onDismiss, className }: OnboardingChecklistCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [done, setDone] = React.useState<string[]>(() => steps.filter((s) => s.done).map((s) => s.id));
  const [open, setOpen] = React.useState<string | null>(() => steps.find((s) => !s.done)?.id ?? null);
  const baseId = React.useId();
  const count = done.length;
  const all = count === steps.length;
  const spring = { type: "spring" as const, stiffness: 340, damping: 32 };

  function complete(id: string) {
    onStep?.(id);
    const next = [...done, id];
    setDone(next);
    setOpen(steps.find((s) => !next.includes(s.id))?.id ?? null);
  }

  return (
    <section aria-labelledby={`${baseId}-t`} className={cn("w-full max-w-[420px] overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm", className)}>
      <header className="px-5 pt-5">
        <div className="flex items-center justify-between gap-3">
          <h3 id={`${baseId}-t`} className="text-[15px] font-semibold text-ink">
            {title}
          </h3>
          <span className="font-mono text-[11px] tabular-nums text-ink-muted">
            {count} of {steps.length} done
          </span>
        </div>
        <div className="mt-3 flex gap-1" aria-hidden>
          {steps.map((s) => (
            <span key={s.id} className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-sunken">
              <motion.span className="block h-full origin-left rounded-full bg-volt" initial={false} animate={{ scaleX: done.includes(s.id) ? 1 : 0 }} transition={reduce ? { duration: 0 } : spring} />
            </span>
          ))}
        </div>
      </header>

      <AnimatePresence mode="popLayout" initial={false}>
        {all ? (
          <motion.div key="all" initial={{ opacity: 0, filter: "blur(8px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0 }} className="flex flex-col items-center px-6 pb-6 pt-8 text-center">
            <motion.span initial={reduce ? false : { scale: 0.3, rotate: -25 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 380, damping: 14 }} className="grid size-12 place-items-center rounded-full bg-volt-soft text-volt-ink">
              <PartyPopper aria-hidden className="size-6" />
            </motion.span>
            <p className="mt-3 text-[15px] font-semibold text-ink">You are all set</p>
            <p className="mt-1 max-w-[260px] text-[13px] text-ink-muted">Your workspace is ready. We will tuck this checklist away.</p>
            <button type="button" onClick={onDismiss} className={cn("mt-4 h-9 rounded-full border border-border-strong/50 px-4 text-[13px] font-medium text-ink hover:bg-surface-hover", focus)}>
              Hide checklist
            </button>
          </motion.div>
        ) : (
          <motion.ol key="list" exit={{ opacity: 0, filter: "blur(8px)" }} className="mt-3 px-2 pb-2">
            {steps.map((s, i) => {
              const isDone = done.includes(s.id);
              const isOpen = open === s.id;
              return (
                <li key={s.id} className={cn("rounded-md transition-colors", isOpen && "bg-surface-sunken/70")}>
                  <button
                    type="button"
                    aria-expanded={isOpen}
                    aria-controls={`${baseId}-${s.id}`}
                    onClick={() => setOpen(isOpen ? null : s.id)}
                    className={cn("flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left", focus)}
                  >
                    <span className={cn("grid size-6 shrink-0 place-items-center rounded-full border text-[11px] font-semibold transition-colors", isDone ? "border-ink bg-ink text-bg" : "border-border-strong/50 text-ink-muted")}>
                      <AnimatePresence mode="popLayout" initial={false}>
                        {isDone ? (
                          <motion.span key="c" initial={reduce ? false : { scale: 0, rotate: -45 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 500, damping: 18 }}>
                            <Check aria-hidden className="size-3.5" strokeWidth={3} />
                          </motion.span>
                        ) : (
                          <motion.span key="n" exit={{ scale: 0 }}>
                            {i + 1}
                          </motion.span>
                        )}
                      </AnimatePresence>
                    </span>
                    <span className={cn("flex-1 text-[13.5px] font-medium", isDone ? "text-ink-subtle line-through decoration-ink-subtle/60" : "text-ink")}>
                      {s.title}
                      <span className="sr-only">{isDone ? ", done" : ""}</span>
                    </span>
                    <ChevronDown aria-hidden className={cn("size-4 text-ink-subtle transition-transform duration-300 ease-spring", isOpen && "rotate-180")} />
                  </button>
                  <AnimatePresence initial={false}>
                    {isOpen && (
                      <motion.div
                        id={`${baseId}-${s.id}`}
                        initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                        animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
                        exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                        transition={spring}
                        className="overflow-hidden"
                      >
                        <div className="pb-3 pl-12 pr-3">
                          <p className="text-[13px] leading-relaxed text-ink-muted">{s.description}</p>
                          {!isDone && (
                            <motion.button type="button" whileTap={reduce ? undefined : { scale: 0.96 }} onClick={() => complete(s.id)} className={cn("mt-3 h-8 rounded-full bg-volt px-3.5 text-[12.5px] font-semibold text-on-volt", focus)}>
                              {s.action}
                            </motion.button>
                          )}
                        </div>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </li>
              );
            })}
          </motion.ol>
        )}
      </AnimatePresence>
    </section>
  );
}

```

### components/examples/welcome-cards/whats-new.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, ChevronLeft, ChevronRight, Sparkles, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface WhatsNewSlide {
  title: string;
  body: string;
  art?: "branches" | "search" | "audit";
  /** Marks the feature as Pro; its button becomes an upgrade. */
  pro?: boolean;
}

export interface WhatsNewCardProps {
  version?: string;
  slides?: WhatsNewSlide[];
  onTry?: (slide: WhatsNewSlide) => void;
  onUpgrade?: () => void;
  onDismiss?: () => void;
  className?: string;
}

const DEFAULT_SLIDES: WhatsNewSlide[] = [
  { title: "Branches for docs", body: "Draft big changes on a branch, get a review, and merge when it reads right.", art: "branches" },
  { title: "Search that reads your mind", body: "Fuzzy matching across tasks, docs and comments. Press / anywhere to start.", art: "search" },
  { title: "Audit log with export", body: "Every permission change and sign-in, filterable and exportable to CSV.", art: "audit", pro: true },
];

const focus = "outline-none focus-visible:shadow-[var(--focus-ring)]";

function Art({ kind }: { kind: WhatsNewSlide["art"] }) {
  return (
    <svg viewBox="0 0 320 120" className="size-full" aria-hidden>
      <rect width="320" height="120" className="fill-surface-sunken" />
      {kind === "search" ? (
        <g>
          <rect x="50" y="26" width="220" height="30" rx="10" className="fill-surface-raised stroke-border" />
          <circle cx="68" cy="41" r="5" fill="none" strokeWidth="2" className="stroke-ink-subtle" />
          <rect x="82" y="38" width="70" height="6" rx="3" className="fill-ink/70" />
          <rect x="50" y="64" width="220" height="38" rx="10" className="fill-surface-raised stroke-border" />
          <rect x="62" y="74" width="8" height="8" rx="2" className="fill-volt" />
          <rect x="78" y="76" width="110" height="5" rx="2.5" className="fill-ink/50" />
          <rect x="62" y="88" width="8" height="8" rx="2" className="fill-ink-subtle/40" />
          <rect x="78" y="90" width="80" height="5" rx="2.5" className="fill-ink/25" />
        </g>
      ) : kind === "audit" ? (
        <g>
          {[0, 1, 2, 3].map((r) => (
            <g key={r} transform={`translate(40 ${20 + r * 22})`}>
              <rect width="240" height="16" rx="5" className={r === 1 ? "fill-ember-soft" : "fill-surface-raised"} />
              <circle cx="12" cy="8" r="4" className={r === 1 ? "fill-ember" : "fill-ink-subtle/50"} />
              <rect x="24" y="5.5" width={90 - r * 10} height="5" rx="2.5" className="fill-ink/40" />
              <rect x="190" y="5.5" width="38" height="5" rx="2.5" className="fill-ink/20" />
            </g>
          ))}
        </g>
      ) : (
        <g fill="none" strokeWidth="2.5" strokeLinecap="round">
          <path d="M30 70h260" className="stroke-ink/30" />
          <path d="M90 70c20 0 20-34 46-34h70c26 0 26 34 46 34" className="stroke-volt-ink" />
          {[30, 90, 290].map((x) => (
            <circle key={x} cx={x} cy="70" r="7" className="fill-surface-raised stroke-ink/40" />
          ))}
          {[150, 200].map((x) => (
            <circle key={x} cx={x} cy="36" r="7" className="fill-volt stroke-volt-ink" />
          ))}
          <circle cx="252" cy="70" r="7" className="fill-surface-raised stroke-volt-ink" />
        </g>
      )}
    </svg>
  );
}

/** Release card: swipe or step through what shipped, with drawn art per feature and a Pro feature that turns the button into an upgrade. */
export function WhatsNewCard({ version = "Version 4.2 · Oct 2", slides = DEFAULT_SLIDES, onTry, onUpgrade, onDismiss, className }: WhatsNewCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [[i, dir], setPage] = React.useState<[number, number]>([0, 1]);
  const slide = slides[i];
  const last = i === slides.length - 1;

  function go(d: number) {
    const n = i + d;
    if (n < 0 || n >= slides.length) return;
    setPage([n, d]);
  }

  const fade = (key: string, content: React.ReactNode, cls?: string) => (
    <motion.div
      key={key}
      initial={reduce ? { opacity: 0 } : { opacity: 0, x: dir * 28, filter: "blur(6px)" }}
      animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
      exit={reduce ? { opacity: 0 } : { opacity: 0, x: dir * -28, filter: "blur(6px)" }}
      transition={{ type: "spring", stiffness: 320, damping: 32 }}
      className={cls}
    >
      {content}
    </motion.div>
  );

  return (
    <section
      aria-roledescription="carousel"
      aria-label="What’s new"
      onKeyDown={(e) => {
        if (e.key === "ArrowRight") go(1);
        if (e.key === "ArrowLeft") go(-1);
      }}
      className={cn("w-full max-w-[400px] overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm", className)}
    >
      <div className="flex items-center justify-between px-5 pt-4">
        <span className="inline-flex items-center gap-1.5 text-[12px] font-semibold text-ink">
          <Sparkles aria-hidden className="size-3.5 text-volt-ink" />
          What’s new
          <span className="font-normal text-ink-subtle">· {version}</span>
        </span>
        <button type="button" aria-label="Dismiss" onClick={onDismiss} className={cn("grid size-7 place-items-center rounded-full text-ink-subtle hover:bg-surface-hover hover:text-ink", focus)}>
          <X aria-hidden className="size-4" />
        </button>
      </div>

      <motion.div
        drag={reduce ? false : "x"}
        dragConstraints={{ left: 0, right: 0 }}
        dragElastic={0.18}
        onDragEnd={(_, info) => {
          if (info.offset.x < -50) go(1);
          else if (info.offset.x > 50) go(-1);
        }}
        className="mx-5 mt-3 h-[120px] cursor-grab overflow-hidden rounded-lg border border-border active:cursor-grabbing"
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {fade(`art-${i}`, <Art kind={slide.art} />, "size-full")}
        </AnimatePresence>
      </motion.div>

      <div className="relative h-[92px] overflow-hidden px-5 pt-4" aria-live="polite">
        <AnimatePresence mode="popLayout" initial={false}>
          {fade(
            `t-${i}`,
            <>
              <h3 className="flex items-center gap-2 text-[15px] font-semibold text-ink">
                {slide.title}
                {slide.pro && <span className="rounded-full bg-ember-soft px-2 py-0.5 text-[10.5px] font-semibold text-ember-ink">Pro</span>}
              </h3>
              <p className="mt-1 text-[13px] leading-relaxed text-ink-muted">{slide.body}</p>
            </>,
          )}
        </AnimatePresence>
      </div>

      <div className="flex items-center justify-between gap-3 border-t border-border px-5 py-3.5">
        <div className="flex items-center gap-1">
          <button type="button" aria-label="Previous update" disabled={i === 0} onClick={() => go(-1)} className={cn("grid size-8 place-items-center rounded-full text-ink-muted hover:bg-surface-hover disabled:opacity-30", focus)}>
            <ChevronLeft aria-hidden className="size-4" />
          </button>
          <div className="flex gap-1.5" aria-label={`Update ${i + 1} of ${slides.length}`} role="img">
            {slides.map((_, k) => (
              <motion.span key={k} animate={{ width: k === i ? 16 : 6 }} transition={{ type: "spring", stiffness: 420, damping: 30 }} className={cn("h-1.5 rounded-full", k === i ? "bg-ink" : "bg-ink-subtle/35")} />
            ))}
          </div>
          <button type="button" aria-label="Next update" disabled={last} onClick={() => go(1)} className={cn("grid size-8 place-items-center rounded-full text-ink-muted hover:bg-surface-hover disabled:opacity-30", focus)}>
            <ChevronRight aria-hidden className="size-4" />
          </button>
        </div>
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.button
            key={slide.pro ? "pro" : "try"}
            type="button"
            initial={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
            whileTap={reduce ? undefined : { scale: 0.96 }}
            onClick={() => (slide.pro ? onUpgrade?.() : onTry?.(slide))}
            className={cn("inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-[13px] font-semibold", slide.pro ? "bg-ember text-on-ember" : "bg-volt text-on-volt", focus)}
          >
            {slide.pro ? "Upgrade to Pro" : "Try it now"}
            <ArrowRight aria-hidden className="size-3.5" />
          </motion.button>
        </AnimatePresence>
      </div>
    </section>
  );
}

```
