{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "focus-cards",
  "type": "registry:ui",
  "title": "Focus Cards",
  "description": "A grid that spotlights one card at a time: pointing at or keyboard-focusing a card keeps it sharp while every sibling dims and blurs. Real list semantics — `ul` › `li` › `article` — and a controlled or uncontrolled active index.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/aceternity/focus-cards.tsx",
      "target": "components/ui/aceternity/focus-cards.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  type ReactNode,\n  useCallback,\n  useState,\n} from \"react\";\n\n// @interlace/focus-cards v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/focus-cards\n// What changed since: https://ds.interlace.tools/c/focus-cards#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — FocusCards + FocusCard\n *\n * A grid that spotlights one card at a time: pointing at or keyboard-focusing\n * a card keeps it sharp while every sibling dims and blurs. Real list\n * semantics — `ul` › `li` › `article` — and a controlled or uncontrolled\n * active index.\n *\n * The media is whatever node you pass: image, `next/image`, video, canvas.\n * Nothing here assumes an `<img>`.\n *\n * ## Provenance\n *\n * Our reimplementation of the Aceternity UI \"Focus Cards\" pattern. What is\n * ours: focus, not just hover, drives the active state (so the grid works from\n * the keyboard); the active index is controllable and reports why it changed;\n * the card is a `renderItem` + `caption` composition rather than a fixed\n * `{src, title}` shape; and all colour comes from tokens.\n *\n * ## Anatomy\n *\n *   FocusCards                       (ul — data-slot=\"focus-cards\")\n *     └─ li                          (data-slot=\"focus-cards-item\",\n *                                     group/focus-item, data-active/data-dimmed,\n *                                     owns the pointer + focus handlers)\n *         └─ FocusCard               (article — data-slot=\"focus-card\")\n *             ├─ div                 (data-slot=\"focus-card-media\")\n *             └─ div                 (data-slot=\"focus-card-caption\")\n *\n * The active/dim state travels down as `group-data-[active]/focus-item` and\n * `group-data-[dimmed]/focus-item` CSS, so `renderItem` never forwards a prop\n * for it. `FocusCard`'s own `active` / `dimmed` props exist only for composing\n * a card outside the grid.\n *\n * ## Motion\n *\n * Tailwind transitions, JS-gated. `FocusCard` calls `useReducedMotion()` and\n * omits three class groups when it returns true: the\n * `transition-[filter,transform,opacity] duration-300` on the card, the\n * `scale-[0.98] blur-sm` / `scale-100 blur-none` pair, and the caption's\n * `transition-opacity`. What survives under `prefers-reduced-motion: reduce`\n * is the part that carries the meaning — `opacity-60` on the receding cards\n * and `opacity-100` on the active one, applied instantly, plus the caption\n * appearing without a fade. The grid stays fully operable; no motion is\n * required to use it.\n *\n * `FocusCards` itself does not read the preference — the gate lives entirely\n * in `FocusCard`, so a consumer who renders something else from `renderItem`\n * owns that contract themselves.\n *\n * ## The caption scrim is dark in both themes, on purpose\n *\n * `--focus-card-scrim` defaults to `black/70%` and\n * `--focus-card-caption-color` to `white`, resolved through the Tailwind theme\n * rather than as literals. A theme-relative token would flip to light in dark\n * mode and fail contrast against the light caption text sitting on arbitrary\n * media. Both are overridable per card via `[--focus-card-scrim:…]`.\n */\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/**\n * Details passed to {@link FocusCardsProps.onActiveChange} alongside the\n * active index. Lets consumers learn *why* the active card changed without\n * inferring it from the event type.\n */\nexport interface FocusCardsChangeDetails {\n  /** What moved focus to (or away from) the card. */\n  reason: \"pointer\" | \"focus\" | \"blur\";\n}\n\n/**\n * Props for a single {@link FocusCard}. Extends the native `<article>` element\n * (a self-contained media + caption unit) so consumers can spread data\n * attributes, ARIA, and listeners onto the card.\n */\nexport interface FocusCardProps\n  extends Omit<ComponentPropsWithoutRef<\"article\">, \"onChange\"> {\n  /**\n   * Caption shown over the media when the card is active. Slot (`ReactNode`),\n   * not a string, so consumers can compose a title + description, a link, an\n   * icon row, etc. Omit for a media-only card.\n   */\n  caption?: ReactNode;\n  /**\n   * The card media — typically an image or `next/image`, but any node works\n   * (video, canvas, gradient). The component never assumes an image element,\n   * so there is no app- or framework-specific media policy baked in.\n   */\n  children?: ReactNode;\n  /**\n   * Force the active (sharp, caption-visible) state regardless of the parent\n   * grid. Inside {@link FocusCards} the active/dim state flows automatically\n   * from the grid via CSS, so leave this unset there; pass it only when\n   * composing a `FocusCard` standalone.\n   * @default false\n   */\n  active?: boolean;\n  /**\n   * Force the dimmed (blurred, receded) state regardless of the parent grid.\n   * Like {@link FocusCardProps.active}, only needed for standalone composition.\n   * @default false\n   */\n  dimmed?: boolean;\n  /**\n   * Stable end-to-end selector. Required at the type level (no runtime\n   * default) so a consumer omission surfaces in review rather than silently\n   * shipping a shared id. Sub-parts derive `{value}-media` / `{value}-caption`.\n   */\n  \"data-testid\": string;\n}\n\n/**\n * Props for {@link FocusCards}.\n *\n * @typeParam TItem - shape of each item in {@link FocusCardsProps.items}.\n */\nexport interface FocusCardsProps<TItem = unknown>\n  extends Omit<ComponentPropsWithoutRef<\"ul\">, \"onChange\" | \"children\"> {\n  /**\n   * Data for each card. The component is data-shape agnostic: provide any\n   * array and render each entry via {@link FocusCardsProps.renderItem}. Keys\n   * come from {@link FocusCardsProps.getItemKey} (falls back to the index).\n   */\n  items: readonly TItem[];\n  /**\n   * Render one card from an item. Return a {@link FocusCard} (recommended) so\n   * the active/dim styling applies, or any node for full control. Receives the\n   * item, its index, and whether it is currently active.\n   */\n  renderItem: (item: TItem, index: number, active: boolean) => ReactNode;\n  /**\n   * Derive a stable React key for an item. Defaults to the array index, which\n   * is only safe for a static, non-reordered list — supply this whenever the\n   * list can reorder or items can be inserted/removed.\n   * @default (_, index) => index\n   */\n  getItemKey?: (item: TItem, index: number) => React.Key;\n  /**\n   * Controlled active card index, or `null` for \"none active\". Pair with\n   * {@link FocusCardsProps.onActiveChange}. Leave undefined to run\n   * uncontrolled via {@link FocusCardsProps.defaultActiveIndex}.\n   */\n  activeIndex?: number | null;\n  /**\n   * Initial active card index for uncontrolled usage.\n   * @default null\n   */\n  defaultActiveIndex?: number | null;\n  /**\n   * Called when the active card changes, with the new index (or `null`) and\n   * the reason it changed. Noun-first per the DS event convention.\n   */\n  onActiveChange?: (\n    index: number | null,\n    details: FocusCardsChangeDetails,\n  ) => void;\n  /**\n   * Number of columns at the largest breakpoint. Mobile is always a single\n   * column; the grid steps up to this count at `md`. Clamped to 1–4 because\n   * the responsive ladder only defines tokens up to four columns.\n   * @default 3\n   */\n  columns?: 1 | 2 | 3 | 4;\n  /**\n   * Stable end-to-end selector for the grid. Required at the type level.\n   * Sub-parts derive `{value}-item-{index}`.\n   */\n  \"data-testid\": string;\n}\n\nconst COLUMN_CLASS: Record<NonNullable<FocusCardsProps[\"columns\"]>, string> = {\n  1: \"md:grid-cols-1\",\n  2: \"md:grid-cols-2\",\n  3: \"md:grid-cols-3\",\n  4: \"md:grid-cols-4\",\n};\n\n/**\n * A single card in a {@link FocusCards} grid. Stays sharp while active; dims\n * and blurs while a sibling is active. Renders as a semantic `<article>` —\n * inside {@link FocusCards} it fills a list item, giving the grid real list\n * semantics for assistive tech.\n *\n * Reduced-motion users get the dim/sharpen contrast without the blur or scale\n * transition, per {@link useReducedMotion}.\n */\nexport function FocusCard({\n  caption,\n  children,\n  active = false,\n  dimmed = false,\n  className,\n  \"data-testid\": testId,\n  ...props\n}: FocusCardProps) {\n  const reducedMotion = useReducedMotion();\n\n  return (\n    <article\n      data-slot=\"focus-card\"\n      // Mirror any manual override onto the card itself so the caption + recede\n      // selectors below resolve identically whether the state comes from the\n      // parent grid (`group/focus-item`) or from these props.\n      data-active={active ? \"\" : undefined}\n      data-dimmed={dimmed && !active ? \"\" : undefined}\n      data-testid={testId}\n      className={cn(\n        // Token-backed surface; fixed aspect ratio reserves space (CLS=0).\n        \"group/card relative isolate aspect-[4/3] size-full overflow-hidden rounded-xl bg-muted\",\n        \"ring-1 ring-border\",\n        // Focus ring for keyboard users landing on a focusable child.\n        \"focus-within:ring-2 focus-within:ring-ring\",\n        !reducedMotion &&\n          \"transition-[filter,transform,opacity] duration-300 ease-out\",\n        // Recede when a sibling is active (driven by the grid's group) or when\n        // forced via the `dimmed` prop — but never while this card is active.\n        \"group-data-[dimmed]/focus-item:opacity-60 data-[dimmed]:opacity-60\",\n        \"group-data-[active]/focus-item:opacity-100 data-[active]:opacity-100\",\n        !reducedMotion &&\n          \"group-data-[dimmed]/focus-item:scale-[0.98] group-data-[dimmed]/focus-item:blur-sm data-[dimmed]:scale-[0.98] data-[dimmed]:blur-sm\",\n        !reducedMotion &&\n          \"group-data-[active]/focus-item:scale-100 group-data-[active]/focus-item:blur-none data-[active]:scale-100 data-[active]:blur-none\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"focus-card-media\"\n        data-testid={`${testId}-media`}\n        className=\"absolute inset-0 size-full [&>*]:size-full [&_img]:size-full [&_img]:object-cover [&_video]:object-cover\"\n      >\n        {children}\n      </div>\n\n      {caption != null && (\n        <div\n          data-slot=\"focus-card-caption\"\n          data-testid={`${testId}-caption`}\n          className={cn(\n            \"absolute inset-x-0 bottom-0 flex items-end p-4 text-(--focus-card-caption-color) sm:p-6\",\n            // Caption sits over arbitrary media, so the scrim is intentionally a\n            // stable dark layer in BOTH themes (a theme-relative token would\n            // flip to light in dark mode and fail contrast against the light\n            // caption text). Both are overridable CSS variables — re-skin via\n            // `[--focus-card-scrim:…]` / `[--focus-card-caption-color:…]` on\n            // the card — with AA-safe defaults resolved through the Tailwind\n            // theme (never raw color literals).\n            \"bg-gradient-to-t from-(--focus-card-scrim) to-transparent\",\n            \"[--focus-card-caption-color:theme(colors.white)] [--focus-card-scrim:theme(colors.black/70%)]\",\n            // Caption fades in once the card (or its grid item) is active.\n            \"opacity-0 group-data-[active]/focus-item:opacity-100 group-data-[active]/card:opacity-100 data-[active]:opacity-100\",\n            !reducedMotion && \"transition-opacity duration-300 ease-out\",\n          )}\n        >\n          {caption}\n        </div>\n      )}\n    </article>\n  );\n}\n\n/**\n * Focus Cards — a responsive grid that spotlights one card at a time. Pointing\n * at or keyboard-focusing a card keeps it sharp while every sibling dims and\n * blurs, so the user's attention follows their cursor or focus ring.\n *\n * Controlled and uncontrolled:\n * ```tsx\n * // Uncontrolled — the grid owns the active index.\n * <FocusCards\n *   data-testid=\"gallery\"\n *   items={photos}\n *   renderItem={(photo, i) => (\n *     <FocusCard data-testid={`gallery-card-${i}`} caption={photo.title}>\n *       <img src={photo.src} alt={photo.title} />\n *     </FocusCard>\n *   )}\n * />\n *\n * // Controlled — drive the active index from outside.\n * <FocusCards\n *   data-testid=\"gallery\"\n *   items={photos}\n *   activeIndex={active}\n *   onActiveChange={(index) => setActive(index)}\n *   renderItem={renderPhoto}\n * />\n * ```\n *\n * Keyboard: Tab moves focus to a focusable child inside a card (e.g. a link in\n * the caption); the card it lives in becomes active via `focusin`, and the\n * focus ring stays visible. No motion is required to operate the grid.\n */\nexport function FocusCards<TItem>({\n  items,\n  renderItem,\n  getItemKey,\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveChange,\n  columns = 3,\n  className,\n  \"data-testid\": testId,\n  ...props\n}: FocusCardsProps<TItem>) {\n  const [uncontrolledIndex, setUncontrolledIndex] = useState<number | null>(\n    defaultActiveIndex,\n  );\n  const controlled = activeIndex !== undefined;\n  const active = controlled ? activeIndex : uncontrolledIndex;\n\n  const setActive = useCallback(\n    (next: number | null, reason: FocusCardsChangeDetails[\"reason\"]) => {\n      if (!controlled) setUncontrolledIndex(next);\n      onActiveChange?.(next, { reason });\n    },\n    [controlled, onActiveChange],\n  );\n\n  return (\n    <ul\n      data-slot=\"focus-cards\"\n      data-testid={testId}\n      className={cn(\n        // Mobile-first: one column, density added at md and up.\n        \"grid w-full grid-cols-1 gap-4 md:gap-6 lg:gap-8\",\n        COLUMN_CLASS[columns],\n        className,\n      )}\n      {...props}\n    >\n      {items.map((item, index) => {\n        const isActive = active === index;\n        return (\n          <li\n            key={getItemKey ? getItemKey(item, index) : index}\n            data-slot=\"focus-cards-item\"\n            data-testid={`${testId}-item-${index}`}\n            // Named group: the rendered card reads `group-data-[active]` /\n            // `group-data-[dimmed]` from here, so the active/dim styling flows\n            // automatically without the consumer forwarding any props.\n            data-active={isActive ? \"\" : undefined}\n            data-dimmed={active !== null && !isActive ? \"\" : undefined}\n            onPointerEnter={() => setActive(index, \"pointer\")}\n            onPointerLeave={() => setActive(null, \"pointer\")}\n            onFocus={() => setActive(index, \"focus\")}\n            onBlur={(event) => {\n              // Only clear when focus leaves the card entirely, not when it\n              // moves between focusable children inside the same card.\n              if (!event.currentTarget.contains(event.relatedTarget)) {\n                setActive(null, \"blur\");\n              }\n            }}\n            className=\"group/focus-item flex\"\n          >\n            {renderItem(item, index, isActive)}\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.2.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/focus-cards\n\nInstalled to `components/ui/aceternity/focus-cards.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/aceternity/focus-cards';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/focus-cards\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
