# Roadmap

> Public product roadmap with Planned, In progress and Shipped columns or a quarter timeline, where cards fly between the two views through shared layout, re-sort by votes as you upvote with a direction-aware rolling count, carry tag badges, and can be filtered by status.

A shadcn/ui block from the [@karaui](https://karaui.kamisama-v0.my.id/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/karaui/roadmap).

- Page, with a live preview: https://shadcn.exchange/r/karaui/roadmap
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/karaui/roadmap-light-3084df866f666f27.png
- Registry item: https://karaui.kamisama-v0.my.id/r/roadmap.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/karaui/roadmap.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `clsx`, `lucide-react`, `motion`, `tailwind-merge`

## Files

### `components/motion/roadmap.tsx`

```tsx
"use client";
// karaui.kamisama-v0.my.id/components/blocks/roadmap

import { ChevronUp, Columns3, ListTree } from "lucide-react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
  type Variants,
} from "motion/react";
import { useCallback, useId, useMemo, useState } from "react";
import { Badge, type BadgeVariant } from "@/components/motion/badge";
import { EmptyState } from "@/components/motion/empty-state";
import { SegmentedControl } from "@/components/motion/segmented-control";
import { ToggleGroup, ToggleGroupItem } from "@/components/motion/toggle-group";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";

export type RoadmapStatus = "planned" | "in-progress" | "shipped";
export type RoadmapView = "board" | "timeline";

export interface RoadmapItem {
  id: string;
  title: string;
  description?: string;
  status: RoadmapStatus;
  tags?: string[];
  votes: number;
  /** The current viewer has upvoted this item. */
  voted?: boolean;
  /** Timeline bucket, e.g. "Q4 2026". Items without one land in "Later". */
  quarter?: string;
}

export interface RoadmapProps {
  items?: RoadmapItem[];
  defaultItems?: RoadmapItem[];
  onItemsChange?: (items: RoadmapItem[]) => void;
  view?: RoadmapView;
  defaultView?: RoadmapView;
  onViewChange?: (view: RoadmapView) => void;
  /** Visible statuses (the status filter). Default: all three. */
  statuses?: RoadmapStatus[];
  defaultStatuses?: RoadmapStatus[];
  onStatusesChange?: (statuses: RoadmapStatus[]) => void;
  /** Fires after the optimistic vote toggle. */
  onVote?: (id: string, voted: boolean) => void;
  /** Quarter order for the timeline view; defaults to first-seen order. */
  quarters?: string[];
  className?: string;
}

const STATUSES: RoadmapStatus[] = ["planned", "in-progress", "shipped"];

const STATUS_LABEL: Record<RoadmapStatus, string> = {
  planned: "Planned",
  "in-progress": "In progress",
  shipped: "Shipped",
};

const STATUS_DOT: Record<RoadmapStatus, string> = {
  planned: "bg-chart-2",
  "in-progress": "bg-warning",
  shipped: "bg-success",
};

const STATUS_BADGE: Record<RoadmapStatus, BadgeVariant> = {
  planned: "secondary",
  "in-progress": "warning",
  shipped: "success",
};

function useControllable<T>(
  value: T | undefined,
  defaultValue: T,
  onChange: ((next: T) => void) | undefined,
) {
  const [internal, setInternal] = useState(defaultValue);
  const controlled = value !== undefined;
  const current = controlled ? value : internal;
  const set = useCallback(
    (next: T) => {
      if (!controlled) setInternal(next);
      onChange?.(next);
    },
    [controlled, onChange],
  );
  return [current, set] as const;
}

const byVotes = (a: RoadmapItem, b: RoadmapItem) => b.votes - a.votes;

export function Roadmap({
  items: itemsProp,
  defaultItems = [],
  onItemsChange,
  view: viewProp,
  defaultView = "board",
  onViewChange,
  statuses: statusesProp,
  defaultStatuses = STATUSES,
  onStatusesChange,
  onVote,
  quarters: quartersProp,
  className,
}: RoadmapProps) {
  const uid = useId();
  const reduce = useReducedMotion() ?? false;
  const [items, setItems] = useControllable(itemsProp, defaultItems, onItemsChange);
  const [view, setView] = useControllable(viewProp, defaultView, onViewChange);
  const [statuses, setStatuses] = useControllable(statusesProp, defaultStatuses, onStatusesChange);

  const visible = useMemo(() => items.filter((i) => statuses.includes(i.status)), [items, statuses]);

  const vote = (id: string) => {
    let next = false;
    setItems(
      items.map((item) => {
        if (item.id !== id) return item;
        next = !item.voted;
        return { ...item, voted: next, votes: item.votes + (next ? 1 : -1) };
      }),
    );
    onVote?.(id, next);
  };

  const quarters = useMemo(() => {
    const seen = quartersProp ? [...quartersProp] : [];
    for (const i of visible) {
      const q = i.quarter ?? "Later";
      if (!seen.includes(q)) seen.push(q);
    }
    return seen.filter((q) => visible.some((i) => (i.quarter ?? "Later") === q));
  }, [visible, quartersProp]);

  const visibleStatuses = STATUSES.filter((s) => statuses.includes(s));

  return (
    <div className={cn("@container flex w-full flex-col gap-4 text-sm", className)}>
      <div className="flex flex-wrap items-center justify-between gap-2">
        <ToggleGroup
          type="multiple"
          variant="outline"
          size="sm"
          aria-label="Filter by status"
          value={statuses}
          onValueChange={(v) => setStatuses(STATUSES.filter((s) => v.includes(s)))}
        >
          {STATUSES.map((s) => (
            <ToggleGroupItem key={s} value={s}>
              <span aria-hidden="true" className={cn("size-2 rounded-full", STATUS_DOT[s])} />
              {STATUS_LABEL[s]}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <SegmentedControl
          aria-label="View"
          size="sm"
          value={view}
          onValueChange={(v) => setView(v as RoadmapView)}
          options={[
            { value: "board", label: "Board", icon: <Columns3 aria-hidden="true" /> },
            { value: "timeline", label: "Timeline", icon: <ListTree aria-hidden="true" /> },
          ]}
        />
      </div>

      {visible.length === 0 ? (
        <EmptyState size="sm" bordered title="Nothing to show" description="Turn on a status to see its items." />
      ) : (
        // Cards share a layoutId across views, so switching Board ↔ Timeline
        // flies each card to its new slot instead of re-rendering the list.
        <LayoutGroup id={uid}>
          {view === "board" ? (
            <div
              className={cn(
                "grid gap-3",
                visibleStatuses.length === 3 && "@lg:grid-cols-3",
                visibleStatuses.length === 2 && "@md:grid-cols-2",
              )}
            >
              {visibleStatuses.map((status) => {
                const column = visible.filter((i) => i.status === status).sort(byVotes);
                return (
                  <section
                    key={status}
                    aria-labelledby={`${uid}-${status}`}
                    className="flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-muted/40 p-2"
                  >
                    <h3
                      id={`${uid}-${status}`}
                      className="flex items-center gap-2 px-1.5 pt-1 text-xs font-medium text-muted-foreground"
                    >
                      <span aria-hidden="true" className={cn("size-2 rounded-full", STATUS_DOT[status])} />
                      {STATUS_LABEL[status]}
                      <span className="ml-auto font-mono tabular-nums">{column.length}</span>
                    </h3>
                    <ul className="flex flex-col gap-2">
                      <AnimatePresence initial={false} mode="popLayout">
                        {column.map((item) => (
                          <RoadmapCard key={item.id} uid={uid} item={item} reduce={reduce} onVote={vote} />
                        ))}
                      </AnimatePresence>
                    </ul>
                  </section>
                );
              })}
            </div>
          ) : (
            <ol className="flex flex-col gap-4">
              {quarters.map((quarter, qi) => (
                <li key={quarter} className="relative grid grid-cols-[4.5rem_1fr] gap-3">
                  <div className="relative pt-2 text-right">
                    <span className="font-mono text-xs font-semibold text-foreground">{quarter}</span>
                    {qi < quarters.length - 1 && (
                      <span aria-hidden="true" className="absolute -right-[7px] top-6 -bottom-6 w-px bg-border" />
                    )}
                    <span
                      aria-hidden="true"
                      className="absolute -right-[11px] top-3 size-2 rounded-full bg-foreground/40 ring-4 ring-background"
                    />
                  </div>
                  <ul className="flex flex-col gap-2 pl-2">
                    <AnimatePresence initial={false} mode="popLayout">
                      {visible
                        .filter((i) => (i.quarter ?? "Later") === quarter)
                        .sort(
                          (a, b) => STATUSES.indexOf(b.status) - STATUSES.indexOf(a.status) || byVotes(a, b),
                        )
                        .map((item) => (
                          <RoadmapCard key={item.id} uid={uid} item={item} reduce={reduce} onVote={vote} showStatus />
                        ))}
                    </AnimatePresence>
                  </ul>
                </li>
              ))}
            </ol>
          )}
        </LayoutGroup>
      )}
    </div>
  );
}

function RoadmapCard({
  uid,
  item,
  reduce,
  onVote,
  showStatus,
}: {
  uid: string;
  item: RoadmapItem;
  reduce: boolean;
  onVote: (id: string) => void;
  showStatus?: boolean;
}) {
  return (
    <motion.li
      layoutId={reduce ? undefined : `${uid}-card-${item.id}`}
      layout={!reduce}
      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, transition: { duration: 0.12 } }}
      transition={SPRING_LAYOUT}
      className="flex gap-3 rounded-lg border border-border bg-card p-3 shadow-sm"
    >
      <div className="min-w-0 flex-1">
        {showStatus && (
          <Badge size="sm" variant={STATUS_BADGE[item.status]} dot className="mb-1.5">
            {STATUS_LABEL[item.status]}
          </Badge>
        )}
        <p className="font-medium leading-snug text-foreground">{item.title}</p>
        {item.description && (
          <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{item.description}</p>
        )}
        {item.tags?.length ? (
          <div className="mt-2 flex flex-wrap gap-1">
            {item.tags.map((tag) => (
              <Badge key={tag} size="sm" variant="outline">
                {tag}
              </Badge>
            ))}
          </div>
        ) : null}
      </div>
      <UpvoteButton count={item.votes} voted={!!item.voted} title={item.title} reduce={reduce} onClick={() => onVote(item.id)} />
    </motion.li>
  );
}

const ROLL: Variants = {
  enter: (d: number) => ({ y: d > 0 ? "100%" : "-100%", opacity: 0 }),
  center: { y: "0%", opacity: 1 },
  exit: (d: number) => ({ y: d > 0 ? "-100%" : "100%", opacity: 0 }),
};
const FADE: Variants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } };

function UpvoteButton({
  count,
  voted,
  title,
  reduce,
  onClick,
}: {
  count: number;
  voted: boolean;
  title: string;
  reduce: boolean;
  onClick: () => void;
}) {
  // Direction is derived during render from the last seen count (React's
  // "adjust state on prop change" pattern), so rolls go up on +1, down on -1.
  const [last, setLast] = useState(count);
  const [direction, setDirection] = useState(1);
  if (count !== last) {
    setDirection(count > last ? 1 : -1);
    setLast(count);
  }
  const variants = reduce ? FADE : ROLL;

  return (
    <motion.button
      type="button"
      aria-pressed={voted}
      aria-label={`Upvote ${title}, ${count} vote${count === 1 ? "" : "s"}`}
      onClick={onClick}
      whileTap={reduce ? undefined : { scale: 0.9 }}
      transition={SPRING_PRESS}
      className={cn(
        "flex h-12 w-10 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-border text-xs font-semibold outline-none transition-colors",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
        voted
          ? "border-primary bg-primary text-primary-foreground"
          : "border-border bg-background text-foreground hover:border-border-strong hover:bg-muted",
      )}
    >
      <motion.span
        aria-hidden="true"
        animate={voted && !reduce ? { y: [0, -3, 0] } : { y: 0 }}
        transition={{ duration: 0.3, ease: EASE_OUT }}
        className="flex"
      >
        <ChevronUp className="size-4" />
      </motion.span>
      <span aria-hidden="true" className="relative flex h-4 overflow-hidden font-mono tabular-nums">
        <AnimatePresence initial={false} mode="popLayout" custom={direction}>
          <motion.span
            key={count}
            custom={direction}
            variants={variants}
            initial="enter"
            animate="center"
            exit="exit"
            transition={SPRING_SWAP}
          >
            {count}
          </motion.span>
        </AnimatePresence>
      </span>
    </motion.button>
  );
}
```

### `components/motion/badge.tsx`

```tsx
"use client";

import { X } from "lucide-react";
import {
  AnimatePresence,
  motion,
  PresenceContext,
  useAnimate,
  useReducedMotion,
  type Variants,
} from "motion/react";
import {
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";

export type BadgeVariant =
  | "default"
  | "secondary"
  | "outline"
  | "destructive"
  | "success"
  | "warning";

export type BadgeSize = "sm" | "md" | "lg";

/** Soft tinted surface or a filled one. Only the status variants differ. */
export type BadgeTone = "soft" | "solid";

const BASE: Record<"default" | "secondary" | "outline", string> = {
  default: "border-transparent bg-primary text-primary-foreground",
  secondary: "border-transparent bg-foreground/[0.07] text-foreground",
  outline: "border-border-strong bg-transparent text-foreground",
};

// Status tints mix the token toward --foreground/--background (as `alert`
// does), so the text keeps its contrast in both themes and every design style.
const SOFT: Record<"destructive" | "success" | "warning", string> = {
  destructive:
    "border-[color-mix(in_oklab,var(--destructive)_30%,transparent)] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] text-[color-mix(in_oklab,var(--destructive)_78%,var(--foreground))]",
  success:
    "border-[color-mix(in_oklab,var(--success)_32%,transparent)] bg-[color-mix(in_oklab,var(--success)_11%,var(--background))] text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]",
  warning:
    "border-[color-mix(in_oklab,var(--warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--warning)_13%,var(--background))] text-[color-mix(in_oklab,var(--warning)_52%,var(--foreground))]",
};

// Filled status badges. Red reads best with light text in both themes;
// green and amber are light hues, so they always get dark text.
const SOLID: Record<"destructive" | "success" | "warning", string> = {
  destructive:
    "border-transparent bg-destructive text-background dark:text-foreground",
  success:
    "border-transparent bg-success text-[color-mix(in_oklab,var(--success)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--success)_22%,var(--background))]",
  warning:
    "border-transparent bg-warning text-[color-mix(in_oklab,var(--warning)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--warning)_22%,var(--background))]",
};

const SIZE: Record<BadgeSize, string> = {
  sm: "h-5 gap-1 px-1.5 text-[11px] [&_svg]:size-3",
  md: "h-6 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
  lg: "h-7 gap-1.5 px-2.5 text-[13px] [&_svg]:size-3.5",
};

// Count badges are circles at one digit and pills beyond.
const COUNT_SIZE: Record<BadgeSize, string> = {
  sm: "h-4 min-w-4 px-1 text-[10px]",
  md: "h-5 min-w-5 px-1.5 text-[11px]",
  lg: "h-6 min-w-6 px-1.5 text-xs",
};

function variantClass(variant: BadgeVariant, tone: BadgeTone) {
  if (variant === "default" || variant === "secondary" || variant === "outline") {
    return BASE[variant];
  }
  return tone === "solid" ? SOLID[variant] : SOFT[variant];
}

export interface BadgeProps {
  children?: ReactNode;
  variant?: BadgeVariant;
  /** Status variants only (destructive/success/warning). Default "soft". */
  tone?: BadgeTone;
  size?: BadgeSize;
  /** Leading status dot in the text color; `"pulse"` adds a radiating ring. */
  dot?: boolean | "pulse";
  /** Leading icon (sized to the badge). Ignored when `dot` is set. */
  icon?: ReactNode;
  /**
   * Makes the badge removable: an × button plays the exit, then calls this.
   * Focus moves to the neighbouring chip's × when the removal came from it.
   */
  onRemove?: () => void;
  /** Accessible name of the × button. Default `Remove <text>`. */
  removeLabel?: string;
  /**
   * Count mode: renders a compact number that rolls up or down as it changes
   * and bumps when it rises. Hidden at 0 unless `showZero`.
   */
  count?: number;
  /** Counts above this read "max+". Default 99. */
  max?: number;
  showZero?: boolean;
  /** Spoken label for count mode, e.g. `(n) => \`${n} unread\``. */
  countLabel?: (count: number) => string;
  className?: string;
}

/**
 * General-purpose badge: six variants, three sizes, an optional (pulsing) dot
 * or icon, removable chips with an exit animation, and a count mode with
 * rolling digits capped at "99+". For animated status transitions
 * (loading → success) use `AnimatedBadge` instead.
 */
export function Badge(props: BadgeProps) {
  if (props.count !== undefined) return <CountBadge {...props} count={props.count} />;
  return <LabelBadge {...props} />;
}

/* -------------------------------------------------------------------------- */
/*                                 Label badge                                */
/* -------------------------------------------------------------------------- */

function LabelBadge({
  children,
  variant = "default",
  tone = "soft",
  size = "md",
  dot = false,
  icon,
  onRemove,
  removeLabel,
  className,
}: BadgeProps) {
  const reduce = useReducedMotion() ?? false;
  // Inside an AnimatePresence the badge pops in when added; anywhere else it
  // renders as-is so server HTML is visible before hydration.
  const inPresence = useContext(PresenceContext) !== null;
  const rootRef = useRef<HTMLSpanElement>(null);
  const [leaving, setLeaving] = useState(false);
  const refocus = useRef<HTMLElement | null>(null);
  const removable = Boolean(onRemove);

  const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 };

  const startRemove = (fromKeyboardFocus: boolean) => {
    if (leaving) return;
    if (fromKeyboardFocus) refocus.current = neighbourRemoveButton(rootRef.current);
    setLeaving(true);
  };

  const finishRemove = () => {
    if (!leaving) return;
    const target = refocus.current;
    refocus.current = null;
    onRemove?.();
    if (target) requestAnimationFrame(() => target.focus({ preventScroll: true }));
  };

  const text = typeof children === "string" ? children : undefined;

  return (
    <motion.span
      ref={rootRef}
      data-badge=""
      // Siblings glide into the gap a removed chip leaves (transform-only).
      layout={removable && !reduce ? "position" : false}
      initial={inPresence ? hidden : false}
      animate={
        leaving
          ? { ...hidden, pointerEvents: "none" }
          : { opacity: 1, scale: 1, pointerEvents: "auto" }
      }
      // Already faded out by the × — leave at once so siblings close the gap.
      exit={{
        ...hidden,
        pointerEvents: "none",
        transition: { duration: leaving ? 0 : 0.12, ease: EASE_OUT },
      }}
      transition={
        leaving
          ? { duration: 0.14, ease: EASE_OUT }
          : reduce
            ? { duration: 0.12 }
            : { ...SPRING_PANEL, layout: SPRING_LAYOUT }
      }
      onAnimationComplete={finishRemove}
      className={cn(
        "inline-flex shrink-0 select-none items-center whitespace-nowrap rounded-full border font-medium leading-none",
        SIZE[size],
        variantClass(variant, tone),
        removable && (size === "sm" ? "pr-0.5" : "pr-1"),
        className,
      )}
    >
      {dot ? (
        <Dot pulse={dot === "pulse" && !reduce} />
      ) : icon ? (
        <span aria-hidden="true" className="inline-flex shrink-0">
          {icon}
        </span>
      ) : null}
      <span className="min-w-0 truncate">{children}</span>
      {removable ? (
        <button
          type="button"
          data-badge-remove=""
          aria-label={removeLabel ?? (text ? `Remove ${text}` : "Remove")}
          onClick={(event) => startRemove(event.detail === 0)}
          onKeyDown={(event: ReactKeyboardEvent<HTMLButtonElement>) => {
            if (event.key === "Backspace" || event.key === "Delete") {
              event.preventDefault();
              startRemove(true);
            }
          }}
          className={cn(
            "inline-flex shrink-0 items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] hover:bg-current/15 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40",
            size === "sm" ? "size-4" : "size-[18px]",
          )}
        >
          <X aria-hidden="true" strokeWidth={2.4} className="size-3!" />
        </button>
      ) : null}
    </motion.span>
  );
}

/** The × of the next chip in the row, else the previous one. */
function neighbourRemoveButton(node: HTMLElement | null) {
  if (!node) return null;
  const pick = (el: Element | null) =>
    el?.querySelector<HTMLElement>("[data-badge-remove]") ?? null;
  let next = node.nextElementSibling;
  while (next && !pick(next)) next = next.nextElementSibling;
  if (next) return pick(next);
  let prev = node.previousElementSibling;
  while (prev && !pick(prev)) prev = prev.previousElementSibling;
  return prev ? pick(prev) : null;
}

// One pulse cycle of the dot's radiating ring, in seconds. Slow on purpose:
// an ambient "live" signal, not feedback, so it sits well outside UI timing.
const PULSE_DURATION = 1.6;

function Dot({ pulse }: { pulse: boolean }) {
  return (
    <span aria-hidden="true" className="relative inline-flex size-1.5 shrink-0">
      {pulse ? (
        <motion.span
          className="absolute inset-0 rounded-full bg-current"
          initial={{ scale: 1, opacity: 0.55 }}
          animate={{ scale: 2.6, opacity: 0 }}
          transition={{
            duration: PULSE_DURATION,
            ease: EASE_OUT,
            repeat: Number.POSITIVE_INFINITY,
          }}
        />
      ) : null}
      <span className="relative size-1.5 rounded-full bg-current" />
    </span>
  );
}

/* -------------------------------------------------------------------------- */
/*                                 Count badge                                */
/* -------------------------------------------------------------------------- */

function CountBadge({
  count,
  max = 99,
  showZero = false,
  countLabel,
  variant = "destructive",
  tone = "solid",
  size = "md",
  className,
}: BadgeProps & { count: number }) {
  const reduce = useReducedMotion() ?? false;
  const [scope, animate] = useAnimate<HTMLSpanElement>();
  const value = Math.max(0, Math.round(count));
  const visible = value > 0 || showZero;
  const text = value > max ? `${max}+` : String(value);

  // Direction is derived during render (not in an effect) so the very render
  // that swaps the digits already rolls them the right way.
  const [seen, setSeen] = useState(value);
  const [direction, setDirection] = useState<1 | -1>(1);
  if (value !== seen) {
    setSeen(value);
    setDirection(value > seen ? 1 : -1);
  }

  // Rising counts bump the pill (not on the pop-in from zero, which has its
  // own entrance).
  const previous = useRef(value);
  useEffect(() => {
    const prev = previous.current;
    previous.current = value;
    if (value > prev && prev > 0 && !reduce && scope.current) {
      animate(scope.current, { scale: [1, 1.18, 1] }, { duration: 0.32, ease: EASE_OUT });
    }
  }, [value, reduce, animate, scope]);

  return (
    <AnimatePresence initial={false}>
      {visible ? (
        <motion.span
          key="count"
          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={
            reduce
              ? { opacity: 0, transition: { duration: 0.1 } }
              : { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: EASE_OUT } }
          }
          transition={reduce ? { duration: 0.12 } : SPRING_PANEL}
          className={cn("inline-flex shrink-0", className)}
        >
          <span
            ref={scope}
            data-badge-count=""
            className={cn(
              "inline-flex items-center justify-center rounded-full border font-semibold leading-none tabular-nums",
              COUNT_SIZE[size],
              variantClass(variant, tone),
            )}
          >
            <span className="sr-only">{countLabel ? countLabel(value) : text}</span>
            <RollingCount text={text} direction={direction} reduce={reduce} />
          </span>
        </motion.span>
      ) : null}
    </AnimatePresence>
  );
}

const roll: Variants = {
  enter: (dir: 1 | -1) => ({ y: dir > 0 ? "100%" : "-100%", opacity: 0 }),
  center: { y: "0%", opacity: 1 },
  exit: (dir: 1 | -1) => ({
    y: dir > 0 ? "-100%" : "100%",
    opacity: 0,
    transition: { duration: 0.14, ease: EASE_OUT },
  }),
};

const fade: Variants = {
  enter: { opacity: 0 },
  center: { opacity: 1 },
  exit: { opacity: 0, transition: { duration: 0.1 } },
};

/**
 * Direction-aware digit roll. `NumberTicker` spins each digit along a 0–9
 * strip, so 9 → 10 would roll the ones digit backwards; here every changed
 * glyph slides in from the direction of change instead.
 */
function RollingCount({
  text,
  direction,
  reduce,
}: {
  text: string;
  direction: 1 | -1;
  reduce: boolean;
}) {
  const chars = text.split("");
  // Keyed by place value from the right so a digit keeps its slot as the
  // number grows on the left.
  const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));
  return (
    <span aria-hidden="true" className="inline-flex">
      {glyphs.map(({ char, place }) => (
        <span
          key={place}
          className="relative inline-flex h-[1.2em] items-center justify-center overflow-hidden"
        >
          <span className="invisible">{char}</span>
          <AnimatePresence initial={false} custom={direction}>
            <motion.span
              key={char}
              custom={direction}
              variants={reduce ? fade : roll}
              initial="enter"
              animate="center"
              exit="exit"
              transition={reduce ? { duration: 0.12 } : SPRING_SWAP}
              className="absolute inset-0 flex items-center justify-center"
            >
              {char}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}
```

### `components/motion/empty-state.tsx`

```tsx
"use client";

import { motion, useReducedMotion, type Variants } from "motion/react";
import type { ReactNode } from "react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";

export type EmptyStateSize = "sm" | "md" | "lg";
export type EmptyStateIllustrationVariant = "documents" | "search";

/* -------------------------------------------------------------------------- */
/*                                Illustration                                */
/* -------------------------------------------------------------------------- */

export interface EmptyStateIllustrationProps {
  variant?: EmptyStateIllustrationVariant;
  /** Width in px; height follows the 5:4 viewBox. */
  size?: number;
  className?: string;
}

const draw = (delay: number, reduce: boolean) => ({
  initial: reduce ? { pathLength: 1, opacity: 0 } : { pathLength: 0, opacity: 0 },
  whileInView: { pathLength: 1, opacity: 1 },
  viewport: { once: true },
  transition: reduce
    ? { duration: 0.2 }
    : {
        pathLength: { duration: 0.5, ease: EASE_OUT, delay },
        opacity: { duration: 0.1, delay },
      },
});

// Idle float: translate only (no rotate/scale), so no SVG transform-origin is
// involved. Rotations in the art are static `transform` attributes on an
// inner <g> that motion never touches.
const float = (reduce: boolean, amplitude: number, duration: number, delay: number) =>
  reduce
    ? {}
    : {
        animate: { y: [0, -amplitude, 0] },
        transition: { duration, ease: EASE_IN_OUT, repeat: Infinity, delay },
      };

/** Small default empty-state art: a floating document stack or a search lens. */
export function EmptyStateIllustration({
  variant = "documents",
  size = 120,
  className,
}: EmptyStateIllustrationProps) {
  const reduce = useReducedMotion() ?? false;
  const lineClass = "stroke-border-strong";

  return (
    <svg
      width={size}
      height={(size * 4) / 5}
      viewBox="0 0 120 96"
      fill="none"
      aria-hidden="true"
      focusable="false"
      className={cn("overflow-visible", className)}
    >
      <ellipse cx="60" cy="88" rx="30" ry="3.5" className="fill-muted" />
      {variant === "documents" ? (
        <>
          <motion.g {...float(reduce, 2, 3.4, 0.4)}>
            <g transform="rotate(-9 46 48)">
              <rect x="24" y="20" width="44" height="56" rx="7" strokeWidth="1.5" className="fill-muted stroke-border-strong" />
            </g>
          </motion.g>
          <motion.g {...float(reduce, 2.5, 3.8, 0.9)}>
            <g transform="rotate(8 74 48)">
              <rect x="52" y="20" width="44" height="56" rx="7" strokeWidth="1.5" className="fill-muted stroke-border-strong" />
            </g>
          </motion.g>
          <motion.g {...float(reduce, 3.5, 3, 0)}>
            <rect x="37" y="16" width="46" height="60" rx="7" strokeWidth="1.5" className="fill-card stroke-border-strong" />
            <motion.path d="M46 30h20" strokeWidth="3" strokeLinecap="round" className="stroke-foreground/70" {...draw(0.25, reduce)} />
            <motion.path d="M46 40h28" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.35, reduce)} />
            <motion.path d="M46 49h24" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.45, reduce)} />
            <motion.path d="M46 58h16" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.55, reduce)} />
            <motion.g
              initial={{ opacity: 0, y: reduce ? 0 : 4 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true }}
              transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.6 }}
            >
              <circle cx="84" cy="17" r="9" className="fill-primary" />
              <path d="M84 13v8M80 17h8" strokeWidth="2" strokeLinecap="round" className="stroke-primary-foreground" />
            </motion.g>
          </motion.g>
        </>
      ) : (
        <>
          <motion.g {...float(reduce, 1.5, 3.6, 0.5)}>
            <rect x="22" y="22" width="64" height="50" rx="8" strokeWidth="1.5" className="fill-muted stroke-border-strong" />
            <motion.path d="M32 36h30" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.2, reduce)} />
            <motion.path d="M32 46h22" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.3, reduce)} />
            <motion.path d="M32 56h26" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.4, reduce)} />
          </motion.g>
          <motion.g {...float(reduce, 3.5, 3, 0)}>
            <circle cx="76" cy="48" r="17" strokeWidth="1.5" className="fill-card stroke-border-strong" />
            <motion.circle cx="76" cy="48" r="11" strokeWidth="3" className="stroke-foreground/70" {...draw(0.35, reduce)} />
            <motion.path d="M84.5 56.5 96 68" strokeWidth="5" strokeLinecap="round" className="stroke-foreground/70" {...draw(0.6, reduce)} />
            <motion.path d="M72.5 44.5l7 7M79.5 44.5l-7 7" strokeWidth="2" strokeLinecap="round" className="stroke-muted-foreground" {...draw(0.75, reduce)} />
          </motion.g>
        </>
      )}
    </svg>
  );
}

/* -------------------------------------------------------------------------- */
/*                                 EmptyState                                 */
/* -------------------------------------------------------------------------- */

export interface EmptyStateProps {
  title: ReactNode;
  description?: ReactNode;
  /**
   * Icon or illustration slot. Defaults to `<EmptyStateIllustration />`;
   * pass `null` for none.
   */
  icon?: ReactNode;
  /** Primary call to action (e.g. a library `Button`). */
  action?: ReactNode;
  /** Secondary call to action, rendered after the primary. */
  secondaryAction?: ReactNode;
  size?: EmptyStateSize;
  /** Dashed border container, for empty regions inside a page. */
  bordered?: boolean;
  /** Heading element for the title. Default `h3`. */
  titleAs?: "h2" | "h3" | "h4" | "p";
  /** Extra content under the actions (links, hints). */
  children?: ReactNode;
  className?: string;
}

const SIZE: Record<
  EmptyStateSize,
  { root: string; title: string; description: string; art: number }
> = {
  sm: { root: "gap-3 px-4 py-6", title: "text-sm", description: "text-xs max-w-[32ch]", art: 88 },
  md: { root: "gap-4 px-6 py-10", title: "text-base", description: "text-sm max-w-[38ch]", art: 120 },
  lg: { root: "gap-5 px-8 py-14", title: "text-lg", description: "text-base max-w-[44ch]", art: 152 },
};

/**
 * Empty-state block: illustration, title, description and actions that
 * stagger in when scrolled into view (opacity + small rise; opacity only under
 * reduced motion).
 */
export function EmptyState({
  title,
  description,
  icon,
  action,
  secondaryAction,
  size = "md",
  bordered = false,
  titleAs: Title = "h3",
  children,
  className,
}: EmptyStateProps) {
  const reduce = useReducedMotion();
  const s = SIZE[size];

  const container: Variants = {
    hidden: {},
    show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },
  };
  const item: Variants = {
    hidden: { opacity: 0, y: reduce ? 0 : 8 },
    show: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT } },
  };

  const art = icon === undefined ? <EmptyStateIllustration size={s.art} /> : icon;

  return (
    <motion.div
      variants={container}
      initial="hidden"
      whileInView="show"
      viewport={{ once: true, amount: 0.4 }}
      className={cn(
        "flex w-full flex-col items-center text-center",
        s.root,
        bordered && "rounded-2xl border border-dashed border-border-strong",
        className,
      )}
    >
      {art != null ? (
        <motion.div variants={item} className="flex items-center justify-center text-muted-foreground">
          {art}
        </motion.div>
      ) : null}
      <div className="flex flex-col items-center gap-1.5">
        <motion.div variants={item}>
          <Title className={cn("font-semibold tracking-tight text-foreground", s.title)}>
            {title}
          </Title>
        </motion.div>
        {description != null ? (
          <motion.p variants={item} className={cn("text-muted-foreground text-balance", s.description)}>
            {description}
          </motion.p>
        ) : null}
      </div>
      {action != null || secondaryAction != null ? (
        <motion.div variants={item} className="flex flex-wrap items-center justify-center gap-2">
          {action}
          {secondaryAction}
        </motion.div>
      ) : null}
      {children != null ? <motion.div variants={item}>{children}</motion.div> : null}
    </motion.div>
  );
}
```

### `components/motion/segmented-control.tsx`

```tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  useCallback,
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";

// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)
// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form
// control: it picks one *value* with radiogroup/radio semantics, can post that
// value with a form via `name`, supports disabled options, icon-only options
// and a full-width layout, and its thumb is a raised card surface rather than
// a filled accent pill. Reach for Tabs when each option owns content below it.

export type SegmentedControlSize = "sm" | "md" | "lg";

export interface SegmentedControlOption {
  value: string;
  label?: ReactNode;
  icon?: ReactNode;
  disabled?: boolean;
  /** Required when the option has only an icon. */
  "aria-label"?: string;
}

const trackSize: Record<SegmentedControlSize, string> = {
  sm: "h-8 p-0.5 text-xs",
  md: "h-10 p-1 text-sm",
  lg: "h-12 p-1 text-sm",
};

const itemSize: Record<SegmentedControlSize, string> = {
  sm: "gap-1.5 px-2.5 [&_svg]:size-3.5",
  md: "gap-2 px-3.5 [&_svg]:size-4",
  lg: "gap-2 px-5 [&_svg]:size-[18px]",
};

function useControllable<T>(
  value: T | undefined,
  defaultValue: T,
  onChange: ((next: T) => void) | undefined,
) {
  const [internal, setInternal] = useState<T>(defaultValue);
  const controlled = value !== undefined;
  const current = controlled ? value : internal;
  const set = useCallback(
    (next: T) => {
      if (!controlled) setInternal(next);
      onChange?.(next);
    },
    [controlled, onChange],
  );
  return [current, set] as const;
}

/** Index of the next enabled option from `from` in `dir`, wrapping around. */
function nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {
  const n = options.length;
  for (let step = 1; step <= n; step++) {
    const i = (((from + dir * step) % n) + n) % n;
    if (!options[i].disabled) return i;
  }
  return from;
}

export interface SegmentedControlProps {
  options: SegmentedControlOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  size?: SegmentedControlSize;
  /** Stretch to the container width with equal-width segments. */
  fullWidth?: boolean;
  disabled?: boolean;
  /** When set, a hidden input posts the value with the surrounding form. */
  name?: string;
  "aria-label"?: string;
  "aria-labelledby"?: string;
  className?: string;
  thumbClassName?: string;
}

export function SegmentedControl({
  options,
  value,
  defaultValue,
  onValueChange,
  size = "md",
  fullWidth = false,
  disabled = false,
  name,
  className,
  thumbClassName,
  ...aria
}: SegmentedControlProps) {
  const [current, setValue] = useControllable(
    value,
    defaultValue ?? options.find((o) => !o.disabled)?.value ?? "",
    onValueChange,
  );
  const reduce = useReducedMotion();
  const layoutId = useId();
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));
  const selectedIndex = items.findIndex((o) => o.value === current);
  // Roving tab stop: the checked radio, or the first enabled one when none is.
  const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled
    ? selectedIndex
    : items.findIndex((o) => !o.disabled);

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    let next: number | null = null;
    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        next = nextEnabled(items, index, 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = nextEnabled(items, index, -1);
        break;
      case "Home":
        next = nextEnabled(items, -1, 1);
        break;
      case "End":
        next = nextEnabled(items, items.length, -1);
        break;
      default:
        return;
    }
    event.preventDefault();
    if (next === null || items[next].disabled) return;
    // Selection follows focus, as the WAI-ARIA radio group pattern prescribes.
    refs.current[next]?.focus();
    if (items[next].value !== current) setValue(items[next].value);
  };

  return (
    // layoutRoot keeps the thumb's shared-layout projection local, so scrolled
    // or fixed ancestors never leak offsets into its glide.
    <motion.div
      layoutRoot
      role="radiogroup"
      aria-disabled={disabled || undefined}
      {...aria}
      className={cn(
        "relative isolate items-stretch rounded-xl border border-border bg-muted",
        fullWidth ? "flex w-full" : "inline-flex",
        trackSize[size],
        disabled && "opacity-60",
        className,
      )}
    >
      {name ? <input type="hidden" name={name} value={current} /> : null}
      {items.map((option, index) => {
        const checked = option.value === current;
        const iconOnly = !option.label;
        return (
          <motion.button
            key={option.value}
            ref={(el) => {
              refs.current[index] = el;
            }}
            type="button"
            role="radio"
            aria-checked={checked}
            aria-label={option["aria-label"]}
            disabled={option.disabled}
            tabIndex={index === tabStop ? 0 : -1}
            data-state={checked ? "checked" : "unchecked"}
            onClick={() => !checked && setValue(option.value)}
            onKeyDown={(e) => onKeyDown(e, index)}
            whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}
            transition={SPRING_PRESS}
            className={cn(
              "relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted",
              "disabled:cursor-not-allowed disabled:opacity-50",
              fullWidth && "flex-1",
              itemSize[size],
              iconOnly && "aspect-square px-0",
              checked
                ? "text-foreground"
                : "text-muted-foreground enabled:hover:text-foreground",
            )}
          >
            {checked ? (
              <motion.span
                layoutId={layoutId}
                aria-hidden
                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
                className={cn(
                  "absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm",
                  thumbClassName,
                )}
              />
            ) : null}
            {option.icon ? (
              <span aria-hidden className="inline-flex shrink-0">
                {option.icon}
              </span>
            ) : null}
            {option.label ? <span className="truncate">{option.label}</span> : null}
          </motion.button>
        );
      })}
    </motion.div>
  );
}

/* ------------------------------------------------------------------------ */
/* ToggleGroup                                                               */
/* ------------------------------------------------------------------------ */

export interface ToggleGroupItem {
  value: string;
  label?: ReactNode;
  icon?: ReactNode;
  disabled?: boolean;
  /** Required when the item has only an icon. */
  "aria-label"?: string;
}

type ToggleGroupBase = {
  items: ToggleGroupItem[];
  size?: SegmentedControlSize;
  disabled?: boolean;
  "aria-label"?: string;
  "aria-labelledby"?: string;
  className?: string;
};

export type ToggleGroupSingleProps = ToggleGroupBase & {
  type: "single";
  /** The pressed item, or `""` when none is pressed. */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Disallow un-pressing the active item, so one is always pressed. */
  required?: boolean;
};

export type ToggleGroupMultipleProps = ToggleGroupBase & {
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
};

export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;

const toggleSize: Record<SegmentedControlSize, string> = {
  sm: "h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
  md: "h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4",
  lg: "h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]",
};

export function ToggleGroup(props: ToggleGroupProps) {
  const { items, size = "md", disabled = false, className } = props;
  const reduce = useReducedMotion();
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const [focusIndex, setFocusIndex] = useState<number | null>(null);

  // Normalise both modes to a string[] internally.
  const single = props.type === "single";
  const [current, setCurrent] = useControllable<string[]>(
    props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),
    single
      ? props.defaultValue
        ? [props.defaultValue as string]
        : []
      : ((props.defaultValue as string[] | undefined) ?? []),
    (next) => {
      if (props.type === "single") props.onValueChange?.(next[0] ?? "");
      else props.onValueChange?.(next);
    },
  );

  const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));
  const firstEnabled = list.findIndex((i) => !i.disabled);
  const tabStop =
    focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;

  const toggle = (itemValue: string) => {
    const pressed = current.includes(itemValue);
    if (props.type === "single") {
      if (pressed && props.required) return;
      setCurrent(pressed ? [] : [itemValue]);
    } else {
      setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);
    }
  };

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    let next: number | null = null;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") next = nextEnabled(list, index, 1);
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = nextEnabled(list, index, -1);
    else if (event.key === "Home") next = nextEnabled(list, -1, 1);
    else if (event.key === "End") next = nextEnabled(list, list.length, -1);
    if (next === null) return;
    event.preventDefault();
    // Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).
    setFocusIndex(next);
    refs.current[next]?.focus();
  };

  return (
    <motion.div
      role="group"
      aria-label={props["aria-label"]}
      aria-labelledby={props["aria-labelledby"]}
      className={cn(
        "inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1",
        disabled && "opacity-60",
        className,
      )}
    >
      {list.map((item, index) => {
        const pressed = current.includes(item.value);
        return (
          <motion.button
            key={item.value}
            ref={(el) => {
              refs.current[index] = el;
            }}
            type="button"
            aria-pressed={pressed}
            aria-label={item["aria-label"]}
            disabled={item.disabled}
            tabIndex={index === tabStop ? 0 : -1}
            data-state={pressed ? "on" : "off"}
            onClick={() => {
              setFocusIndex(index);
              toggle(item.value);
            }}
            onFocus={() => setFocusIndex(index)}
            onKeyDown={(e) => onKeyDown(e, index)}
            whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}
            transition={SPRING_PRESS}
            className={cn(
              "relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
              "disabled:cursor-not-allowed disabled:opacity-50",
              toggleSize[size],
              pressed
                ? "text-foreground"
                : "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
            )}
          >
            <AnimatePresence initial={false}>
              {pressed ? (
                <motion.span
                  key="on"
                  aria-hidden
                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}
                  transition={SPRING_SWAP}
                  className="absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong"
                />
              ) : null}
            </AnimatePresence>
            {item.icon ? (
              <span aria-hidden className="inline-flex shrink-0">
                {item.icon}
              </span>
            ) : null}
            {item.label ? <span>{item.label}</span> : null}
          </motion.button>
        );
      })}
    </motion.div>
  );
}
```

### `components/motion/toggle-group.tsx`

```tsx
"use client";

import {
  AnimatePresence,
  type HTMLMotionProps,
  motion,
  useReducedMotion,
} from "motion/react";
import {
  createContext,
  forwardRef,
  type KeyboardEvent,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useId,
  useRef,
  useState,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";

// Toggle / ToggleGroup vs SegmentedControl: `SegmentedControl`
// (segmented-control.tsx) is a radiogroup form control that always holds one
// value and ships a data-driven `ToggleGroup` for quick `items` arrays. This
// file is the composable, shadcn-shaped toolbar primitive: a standalone
// `Toggle`, and a `ToggleGroup` you fill with `ToggleGroupItem` children
// (icons, labels, tooltips, anything), with `default`/`outline` variants. In
// single mode one pressed surface glides between items; in multiple mode each
// item pops its own surface in and out.

export type ToggleVariant = "default" | "outline";
export type ToggleSize = "sm" | "md" | "lg";

const sizeClass: Record<ToggleSize, string> = {
  sm: "h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
  md: "h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4",
  lg: "h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]",
};

const surfaceClass: Record<ToggleVariant, string> = {
  default: "bg-muted ring-1 ring-border-strong",
  outline: "bg-muted",
};

const itemBase =
  "relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium outline-none transition-colors duration-200 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";

function useControllable<T>(
  value: T | undefined,
  defaultValue: T,
  onChange: ((next: T) => void) | undefined,
) {
  const [internal, setInternal] = useState<T>(defaultValue);
  const controlled = value !== undefined;
  const current = controlled ? value : internal;
  const set = useCallback(
    (next: T) => {
      if (!controlled) setInternal(next);
      onChange?.(next);
    },
    [controlled, onChange],
  );
  return [current, set] as const;
}

type ButtonBaseProps = Omit<HTMLMotionProps<"button">, "children" | "value" | "type">;

/** The pressed surface that pops in and out behind a standalone toggle or a multiple-mode item. */
function PopSurface({ show, variant }: { show: boolean; variant: ToggleVariant }) {
  const reduce = useReducedMotion();
  return (
    <AnimatePresence initial={false}>
      {show ? (
        <motion.span
          key="on"
          aria-hidden
          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={
            reduce
              ? { opacity: 0 }
              : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }
          }
          transition={SPRING_SWAP}
          className={cn("absolute inset-0 -z-10 rounded-[inherit]", surfaceClass[variant])}
        />
      ) : null}
    </AnimatePresence>
  );
}

/* ------------------------------------------------------------------------ */
/* Toggle                                                                    */
/* ------------------------------------------------------------------------ */

export interface ToggleProps extends ButtonBaseProps {
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
  variant?: ToggleVariant;
  size?: ToggleSize;
  children?: ReactNode;
}

/** A two-state button (`aria-pressed`) whose pressed surface springs in and out. */
export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(
  {
    pressed,
    defaultPressed = false,
    onPressedChange,
    variant = "default",
    size = "md",
    disabled,
    className,
    children,
    onClick,
    ...rest
  },
  ref,
) {
  const reduce = useReducedMotion();
  const [on, setOn] = useControllable(pressed, defaultPressed, onPressedChange);
  return (
    <motion.button
      ref={ref}
      type="button"
      aria-pressed={on}
      data-state={on ? "on" : "off"}
      disabled={disabled}
      whileTap={reduce || disabled ? undefined : { scale: 0.9 }}
      transition={SPRING_PRESS}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) setOn(!on);
      }}
      className={cn(
        itemBase,
        "rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-background",
        sizeClass[size],
        variant === "outline" && "border border-border",
        on
          ? "text-foreground"
          : "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
        className,
      )}
      {...rest}
    >
      <PopSurface show={on} variant={variant} />
      {children}
    </motion.button>
  );
});

/* ------------------------------------------------------------------------ */
/* ToggleGroup                                                               */
/* ------------------------------------------------------------------------ */

interface ToggleGroupContextValue {
  type: "single" | "multiple";
  pressed: string[];
  toggle: (value: string) => void;
  variant: ToggleVariant;
  size: ToggleSize;
  disabled: boolean;
  layoutId: string;
  tabStop: string | null;
  setTabStop: (value: string) => void;
}

const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);

type ToggleGroupBase = Omit<
  HTMLMotionProps<"div">,
  "children" | "defaultValue" | "onChange" | "dir"
> & {
  variant?: ToggleVariant;
  size?: ToggleSize;
  disabled?: boolean;
  /** Arrow-key axis for roving focus. Default "horizontal" (both axes still work). */
  orientation?: "horizontal" | "vertical";
  children?: ReactNode;
};

export type ToggleGroupSingleProps = ToggleGroupBase & {
  type: "single";
  /** The pressed item, or `""` when none is pressed. */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Disallow un-pressing the active item, so one is always pressed. */
  required?: boolean;
};

export type ToggleGroupMultipleProps = ToggleGroupBase & {
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
};

export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;

const ITEM_SELECTOR = "[data-toggle-group-item]";

function enabledItems(root: HTMLElement | null) {
  if (!root) return [];
  return Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(
    (el) => !el.disabled && el.closest("[data-toggle-group]") === root,
  );
}

/**
 * Toolbar-style group of `ToggleGroupItem`s. `type="single"` behaves like a
 * set of mutually exclusive toggles (deselectable unless `required`) whose
 * pressed surface glides between items; `type="multiple"` presses any subset.
 * One tab stop, arrow keys / Home / End rove between items, Space/Enter press.
 */
export function ToggleGroup(props: ToggleGroupProps) {
  const {
    type,
    value: _value,
    defaultValue: _defaultValue,
    onValueChange: _onValueChange,
    variant = "default",
    size = "md",
    disabled = false,
    orientation = "horizontal",
    className,
    children,
    onKeyDown,
    ...rest
  } = props;
  // `required` only exists on the single variant; keep it off the DOM.
  const { required: _required, ...domProps } = rest as typeof rest & { required?: boolean };
  const single = props.type === "single";
  const required = props.type === "single" && Boolean(props.required);

  const [pressed, setPressed] = useControllable<string[]>(
    props.value === undefined
      ? undefined
      : props.type === "single"
        ? props.value
          ? [props.value]
          : []
        : props.value,
    props.type === "single"
      ? props.defaultValue
        ? [props.defaultValue]
        : []
      : (props.defaultValue ?? []),
    (next) => {
      if (props.type === "single") props.onValueChange?.(next[0] ?? "");
      else props.onValueChange?.(next);
    },
  );

  const layoutId = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const [tabStop, setTabStop] = useState<string | null>(pressed[0] ?? null);

  // Keep exactly one reachable tab stop: if the remembered item vanished or
  // got disabled, fall back to the first pressed item, then the first enabled.
  useEffect(() => {
    const items = enabledItems(rootRef.current);
    if (items.some((el) => el.dataset.value === tabStop)) return;
    const fallback =
      items.find((el) => pressed.includes(el.dataset.value ?? "")) ?? items[0];
    const next = fallback?.dataset.value ?? null;
    if (next !== tabStop) setTabStop(next);
  });

  const toggle = (itemValue: string) => {
    const isOn = pressed.includes(itemValue);
    if (single) {
      if (isOn && required) return;
      setPressed(isOn ? [] : [itemValue]);
    } else {
      setPressed(isOn ? pressed.filter((v) => v !== itemValue) : [...pressed, itemValue]);
    }
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    const items = enabledItems(rootRef.current);
    const index = items.indexOf(document.activeElement as HTMLButtonElement);
    if (index < 0) return;
    const forward = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
    const backward = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
    let next: number;
    if (event.key === forward || event.key === "ArrowDown" || event.key === "ArrowRight")
      next = (index + 1) % items.length;
    else if (event.key === backward || event.key === "ArrowUp" || event.key === "ArrowLeft")
      next = (index - 1 + items.length) % items.length;
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = items.length - 1;
    else return;
    event.preventDefault();
    // Focus moves; pressing stays on Space/Enter — these are toggles, not radios.
    items[next].focus();
  };

  return (
    <ToggleGroupContext.Provider
      value={{
        type,
        pressed,
        toggle,
        variant,
        size,
        disabled,
        layoutId,
        tabStop,
        setTabStop,
      }}
    >
      {/* layoutRoot keeps the gliding surface's projection local to the group,
          so scrolled or transformed ancestors never skew the glide. */}
      <motion.div
        ref={rootRef}
        layoutRoot
        role="group"
        data-toggle-group=""
        data-orientation={orientation}
        aria-disabled={disabled || undefined}
        onKeyDown={handleKeyDown}
        className={cn(
          "inline-flex w-fit items-center",
          orientation === "vertical" && "flex-col items-stretch",
          variant === "default"
            ? "gap-0.5 rounded-xl border border-border bg-card p-1"
            : "rounded-lg",
          disabled && "opacity-60",
          className,
        )}
        {...domProps}
      >
        {children}
      </motion.div>
    </ToggleGroupContext.Provider>
  );
}

export interface ToggleGroupItemProps extends ButtonBaseProps {
  value: string;
  children?: ReactNode;
}

export const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
  function ToggleGroupItem(
    { value, disabled: itemDisabled, className, children, onClick, onFocus, ...rest },
    ref,
  ) {
    const ctx = useContext(ToggleGroupContext);
    if (!ctx) throw new Error("ToggleGroupItem must be used inside <ToggleGroup>.");
    const reduce = useReducedMotion();
    const disabled = ctx.disabled || Boolean(itemDisabled);
    const on = ctx.pressed.includes(value);
    const outline = ctx.variant === "outline";

    return (
      <motion.button
        ref={ref}
        type="button"
        data-toggle-group-item=""
        data-value={value}
        data-state={on ? "on" : "off"}
        aria-pressed={on}
        disabled={disabled}
        tabIndex={ctx.tabStop === value ? 0 : -1}
        whileTap={reduce || disabled ? undefined : { scale: 0.88 }}
        transition={SPRING_PRESS}
        onFocus={(event) => {
          ctx.setTabStop(value);
          onFocus?.(event);
        }}
        onClick={(event) => {
          onClick?.(event);
          if (event.defaultPrevented) return;
          ctx.setTabStop(value);
          ctx.toggle(value);
        }}
        className={cn(
          itemBase,
          sizeClass[ctx.size],
          outline
            ? // Joined segments: neighbours overlap their shared border.
              "rounded-none border border-border bg-card -ml-px first:ml-0 first:rounded-l-lg last:rounded-r-lg in-data-[orientation=vertical]:-mt-px in-data-[orientation=vertical]:ml-0 in-data-[orientation=vertical]:first:mt-0 in-data-[orientation=vertical]:first:rounded-l-none in-data-[orientation=vertical]:first:rounded-t-lg in-data-[orientation=vertical]:last:rounded-r-none in-data-[orientation=vertical]:last:rounded-b-lg"
            : "rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-card",
          on
            ? "text-foreground"
            : "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
          className,
        )}
        {...rest}
      >
        {ctx.type === "single" ? (
          on ? (
            <motion.span
              layoutId={ctx.layoutId}
              aria-hidden
              transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
              className={cn(
                "absolute inset-0 -z-10 rounded-[inherit]",
                surfaceClass[ctx.variant],
              )}
            />
          ) : null
        ) : (
          <PopSurface show={on} variant={ctx.variant} />
        )}
        {children}
      </motion.button>
    );
  },
);
```

### `lib/ease.ts`

```ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.

export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;

/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";

/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
  type: "spring",
  stiffness: 500,
  damping: 30,
  mass: 0.6,
} as const;

/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
  type: "spring",
  stiffness: 460,
  damping: 30,
  mass: 0.55,
} as const;

/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
  type: "spring",
  stiffness: 420,
  damping: 40,
  mass: 0.5,
} as const;

/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
  type: "spring",
  stiffness: 360,
  damping: 32,
  mass: 0.6,
} as const;

/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
  stiffness: 200,
  damping: 15,
  mass: 0.3,
} as const;

/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
 * so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
  stiffness: 700,
  damping: 50,
  mass: 0.5,
} as const;
```

### `lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```
