{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gallery-card",
  "title": "Gallery card",
  "description": "A catalogue card: a poster or a drawn fallback with the name, a loop video on hover or focus, the tier badge, category and new, and one link stretched over the card.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/badge"
  ],
  "files": [
    {
      "path": "registry/manniche/gallery-card/gallery-card.tsx",
      "content": "// Gallery card: a poster, the item's name as its link, a short description and a few labels (tier, category, new).\n// The whole card is one link target, so it reads as one thing; the name stays the link text, and the tier badge and\n// the description describe it, so a screen reader hears \"flux-image, link, Pro, Flowing image…\" and nothing twice.\n//\n// Without a poster (or when the image fails) the card draws its own: the name set large in mono, split at its\n// hyphens, on a dotted ground. With a `loop` video the poster comes alive on hover (after a short dwell, so a quick\n// pass does not start anything) or on keyboard focus: a small chip says it plays, and a thin bar runs with it. The\n// video is fetched only the first time the card is hovered or focused, never plays under reduced motion, and if it\n// fails the chip says so and the still image stays.\n//\n// `href` is a real link, so the card works without JS and Cmd/Ctrl/Shift/middle click open it as usual. Pass\n// `onOpen` to open a detail view in place instead: a plain click (or Enter) calls it and the link does not navigate.\n//\n// Motion: the video, the chip and the \"↵ open\" hint fade in (opacity, 150 ms); the card sinks 1 px on press. Under\n// reduced motion there is no video at all and the hint appears without a fade.\nimport { Play } from 'lucide-react'\nimport { useEffect, useId, useRef, useState, type MouseEvent, type PointerEvent } from 'react'\nimport { Badge } from '@/registry/manniche/badge/badge'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\n\nexport type GalleryCardLabels = {\n  /** The tier badge for free items. */\n  free: string\n  /** The tier badge for paid items. */\n  pro: string\n  /** The badge on recently added items. */\n  new: string\n  /** The hint after the ↵ key, shown on hover and focus. */\n  open: string\n  /** The chip while the loop plays (the length in seconds follows it). */\n  loop: string\n  /** The chip while the loop is still loading. */\n  loading: string\n  /** The chip when the loop could not play; the still image stays. */\n  failed: string\n  /** The file type in the corner of the drawn poster. */\n  fileTag: string\n}\n\nconst LABELS: GalleryCardLabels = {\n  free: 'Free',\n  pro: 'Pro',\n  new: 'New',\n  open: 'open',\n  loop: 'Loop',\n  loading: 'Loading loop',\n  failed: 'No loop · still image',\n  fileTag: '.tsx',\n}\n\nexport type GalleryCardProps = {\n  /** The item's name (a slug like \"flux-image\"). It is the link text and, without a poster, the poster. */\n  name: string\n  /** Where the card leads. Always a real URL, so the card works without JS and opens in a new tab on Cmd/Ctrl click. */\n  href: string\n  /** One or two sentences; clamped to two lines. */\n  description?: string\n  /** The language of the description, when it differs from the page (e.g. English descriptions on a Danish page). */\n  descriptionLang?: string\n  /** Which tier badge to show. */\n  tier?: 'free' | 'pro'\n  /** A category label under the description, e.g. \"surfaces\". */\n  category?: string\n  /** Marks the item as recently added. */\n  isNew?: boolean\n  /** Poster image (16:10). Decorative: the name is the link text. Without one, or if it fails, the card draws its own. */\n  poster?: string\n  /** A short muted video that loops on hover and focus. Fetched on first hover/focus; never plays under reduced motion. */\n  loop?: string\n  /** Called on a plain click or Enter instead of navigating, e.g. to open a detail view. Modified clicks keep the link. */\n  onOpen?: (event: MouseEvent<HTMLAnchorElement>) => void\n  /** The heading level of the name. */\n  headingLevel?: 2 | 3 | 4 | 5 | 6\n  /** Every visible string, for other languages. */\n  labels?: Partial<GalleryCardLabels>\n  className?: string\n}\n\ntype LoopState = 'loading' | 'playing' | 'failed'\n\n/** The drawn poster: the name large in mono, one hyphen-separated part per line (short names) or wrapping (long ones). */\nfunction DrawnPoster({ name, category, fileTag }: { name: string; category?: string; fileTag: string }) {\n  const parts = name.split('-')\n  const long = parts.length > 3 || name.length > 28\n  return (\n    <div\n      aria-hidden\n      className=\"@container absolute inset-0 flex flex-col justify-between bg-muted bg-[radial-gradient(color-mix(in_oklab,var(--foreground)_11%,transparent)_1px,transparent_1.3px)] bg-size-[14px_14px] bg-position-[7px_7px] px-4 pt-3.5 pb-4\"\n    >\n      <div className=\"flex justify-between gap-3 font-mono text-[11px] leading-none font-medium text-muted-foreground\">\n        <span className=\"truncate\">{category}</span>\n        <span>{fileTag}</span>\n      </div>\n      <div\n        className={cn(\n          'min-w-0 font-mono leading-[1.04] font-medium tracking-[-0.04em] [overflow-wrap:anywhere] text-foreground/90',\n          long ? 'line-clamp-3 text-[clamp(17px,7cqi,24px)]' : 'text-[clamp(20px,8.5cqi,28px)]',\n        )}\n      >\n        {parts.map((part, i) => (\n          <span key={i} className={long ? undefined : 'block'}>\n            {part}\n            {i < parts.length - 1 && <span className=\"text-muted-foreground\">-</span>}\n            {long && i < parts.length - 1 && <wbr />}\n          </span>\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/** A poster card for a gallery: the name is the link, stretched over the whole card. */\nexport function GalleryCard({\n  name,\n  href,\n  description,\n  descriptionLang,\n  tier = 'free',\n  category,\n  isNew = false,\n  poster,\n  loop,\n  onOpen,\n  headingLevel = 3,\n  labels: labelsProp,\n  className,\n}: GalleryCardProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const reduced = useReducedMotion()\n  const tierId = useId()\n  const descId = useId()\n  const Heading = `h${headingLevel}` as const\n\n  const [imageFailed, setImageFailed] = useState(false)\n  const [hovered, setHovered] = useState(false)\n  const [focused, setFocused] = useState(false)\n  const [armed, setArmed] = useState(false)\n  const [loopState, setLoopState] = useState<LoopState>('loading')\n  const [duration, setDuration] = useState<number | null>(null)\n  const [run, setRun] = useState(0)\n  const dwell = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const video = useRef<HTMLVideoElement>(null)\n  const image = useRef<HTMLImageElement>(null)\n\n  // A new poster gets a fresh chance to load.\n  const [lastPoster, setLastPoster] = useState(poster)\n  if (poster !== lastPoster) {\n    setLastPoster(poster)\n    setImageFailed(false)\n  }\n\n  const canLoop = Boolean(loop) && !reduced\n  const active = canLoop && (hovered || focused)\n\n  useEffect(() => () => {\n    if (dwell.current) clearTimeout(dwell.current)\n  }, [])\n\n  // A server-rendered poster can fail before React is listening, and then onError never fires: check it once mounted.\n  useEffect(() => {\n    const img = image.current\n    if (img?.complete && img.naturalWidth === 0) setImageFailed(true)\n  }, [poster])\n\n  // Play while active, rewind when not, so the next hover starts from the top.\n  useEffect(() => {\n    const v = video.current\n    if (!v) return\n    if (active) {\n      v.play().catch((error: unknown) => {\n        // An interrupted play (pause during load) is normal; a refused or unsupported one means no loop here.\n        if (error instanceof DOMException && error.name !== 'AbortError') setLoopState('failed')\n      })\n    } else {\n      v.pause()\n      if (v.readyState > 0) v.currentTime = 0\n    }\n  }, [active, armed])\n\n  // A card left alone for a while lets its video go, so sweeping across a grid does not keep dozens buffered.\n  useEffect(() => {\n    if (active || !armed) return\n    const t = setTimeout(() => {\n      setArmed(false)\n      setLoopState((s) => (s === 'failed' ? s : 'loading'))\n    }, 4000)\n    return () => clearTimeout(t)\n  }, [active, armed])\n\n  const activate = () => {\n    if (!canLoop) return\n    if (!armed) setArmed(true)\n  }\n\n  const onPointerEnter = (e: PointerEvent<HTMLElement>) => {\n    if (!canLoop || e.pointerType === 'touch' || dwell.current) return\n    dwell.current = setTimeout(() => {\n      dwell.current = null\n      activate()\n      setHovered(true)\n    }, 120)\n  }\n  const onPointerLeave = () => {\n    if (dwell.current) clearTimeout(dwell.current)\n    dwell.current = null\n    setHovered(false)\n  }\n\n  const onClick = (e: MouseEvent<HTMLAnchorElement>) => {\n    if (!onOpen || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return\n    e.preventDefault()\n    onOpen(e)\n  }\n\n  const showImage = Boolean(poster) && !imageFailed\n  const chip = loopState === 'playing' ? labels.loop : loopState === 'failed' ? labels.failed : labels.loading\n  const describedBy = description ? `${tierId} ${descId}` : tierId\n\n  return (\n    <article\n      data-loop={active ? loopState : undefined}\n      onPointerEnter={onPointerEnter}\n      onPointerLeave={onPointerLeave}\n      onFocus={(e) => {\n        // Keyboard focus only: a mouse click also focuses the link, and must not leave the loop running.\n        if (!(e.target as Element).matches(':focus-visible')) return\n        activate()\n        setFocused(true)\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false)\n      }}\n      className={cn(\n        'group/card relative flex min-w-0 flex-col rounded-[calc(var(--radius)*2+2px)] bg-card p-3 text-card-foreground',\n        'shadow-[inset_0_-2px_0_color-mix(in_oklab,var(--foreground)_6%,transparent),0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]',\n        'dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_-2px_0_rgb(0_0_0/0.38),0_0_0_1px_rgb(255_255_255/0.07)]',\n        'transition-transform duration-100 ease-out-quint active:translate-y-px motion-reduce:transition-none motion-reduce:active:translate-y-0',\n        'has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-[3px] has-[a:focus-visible]:outline-ring',\n        'after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-foreground/3 after:opacity-0 after:transition-opacity after:duration-150 after:ease-out-quint hover:after:opacity-100 motion-reduce:after:transition-none',\n        className,\n      )}\n    >\n      <div className=\"relative isolate aspect-[16/10] overflow-hidden rounded-[14px] bg-muted\">\n        {showImage ? (\n          <img\n            ref={image}\n            src={poster}\n            alt=\"\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            onError={() => setImageFailed(true)}\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n        ) : (\n          <DrawnPoster name={name} category={category} fileTag={labels.fileTag} />\n        )}\n\n        {canLoop && armed && (\n          <video\n            ref={video}\n            src={loop}\n            muted\n            loop\n            playsInline\n            preload=\"auto\"\n            disablePictureInPicture\n            aria-hidden\n            tabIndex={-1}\n            onLoadedMetadata={(e) => {\n              const d = e.currentTarget.duration\n              if (Number.isFinite(d) && d > 0) setDuration(d)\n            }}\n            onWaiting={() => setLoopState('loading')}\n            onPlaying={() => {\n              setLoopState('playing')\n              setRun((n) => n + 1)\n            }}\n            onError={() => setLoopState('failed')}\n            className={cn(\n              'absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-150 ease-out-quint',\n              active && loopState === 'playing' && 'opacity-100',\n            )}\n          />\n        )}\n\n        {/* The image's edge: pure black or white at 10 %, so it never tints with the surface below. */}\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 z-[2] rounded-[inherit] shadow-[inset_0_0_0_1px_oklch(0_0_0/0.1)] dark:shadow-[inset_0_0_0_1px_oklch(1_0_0/0.1)]\"\n        />\n\n        {canLoop && (\n          <>\n            <span\n              aria-hidden\n              className={cn(\n                'pointer-events-none absolute top-2.5 left-2.5 z-[3] inline-flex h-[26px] items-center gap-[7px] rounded-full bg-background/85 pr-2.5 pl-2 text-xs leading-none font-medium text-foreground opacity-0 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_12%,transparent)] transition-opacity duration-150 ease-out-quint',\n                active && 'opacity-100',\n                loopState === 'failed' && 'pl-2.5 text-muted-foreground',\n              )}\n            >\n              {loopState === 'playing' && <Play className=\"size-2.5 fill-current\" strokeWidth={0} />}\n              {chip}\n              {loopState === 'playing' && duration !== null && (\n                <span className=\"text-muted-foreground tabular-nums\">{Math.round(duration)} s</span>\n              )}\n            </span>\n            <span\n              aria-hidden\n              className={cn(\n                'pointer-events-none absolute inset-x-0 bottom-0 z-[3] h-0.5 overflow-hidden bg-foreground/15 opacity-0 transition-opacity duration-150 ease-out-quint',\n                active && loopState !== 'failed' && 'opacity-100',\n              )}\n            >\n              {loopState === 'playing' ? (\n                <i\n                  key={run}\n                  className=\"block h-full origin-left bg-foreground [animation:manniche-loop-run_var(--loop-d)_linear_infinite]\"\n                  style={{ ['--loop-d' as string]: `${duration ?? 4}s` }}\n                />\n              ) : (\n                <i className=\"block h-full w-[30%] bg-foreground/60 [animation:manniche-loop-wait_1.2s_cubic-bezier(0.23,1,0.32,1)_infinite]\" />\n              )}\n            </span>\n            <style href=\"manniche-gallery-card\" precedence=\"default\">\n              {\n                '@keyframes manniche-loop-run { from { transform: scaleX(0) } to { transform: scaleX(1) } } @keyframes manniche-loop-wait { from { transform: translateX(-100%) } to { transform: translateX(340%) } }'\n              }\n            </style>\n          </>\n        )}\n      </div>\n\n      <div className=\"grid gap-[9px] px-2 pt-3.5 pb-1.5\">\n        <div className=\"flex min-w-0 items-center justify-between gap-3\">\n          <Heading className=\"min-w-0 truncate font-mono text-[15px] leading-[1.25] font-medium tracking-[-0.015em]\">\n            <a\n              href={href}\n              onClick={onClick}\n              aria-describedby={describedBy}\n              className=\"outline-none before:absolute before:inset-0 before:z-[1] before:rounded-[calc(var(--radius)*2+2px)]\"\n            >\n              {name}\n            </a>\n          </Heading>\n          <Badge id={tierId} variant={tier}>\n            {tier === 'pro' ? labels.pro : labels.free}\n          </Badge>\n        </div>\n        {description && (\n          <p id={descId} lang={descriptionLang} className=\"line-clamp-2 min-h-[3em] text-sm leading-normal text-pretty text-muted-foreground\">\n            {description}\n          </p>\n        )}\n        <div className=\"flex min-h-[22px] items-center gap-1.5\">\n          {category && <Badge>{category}</Badge>}\n          {isNew && <Badge variant=\"new\">{labels.new}</Badge>}\n          <span\n            aria-hidden\n            className=\"ml-auto inline-flex items-center gap-1.5 text-[12.5px] leading-none text-muted-foreground opacity-0 transition-opacity duration-150 ease-out-quint group-focus-within/card:opacity-100 group-hover/card:opacity-100 motion-reduce:transition-none [@media(hover:none)]:hidden\"\n          >\n            <kbd className=\"inline-grid h-5 min-w-5 place-items-center rounded-[5px] bg-muted px-[5px] font-mono text-[11px] text-muted-foreground shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_8%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.18),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)]\">\n              ↵\n            </kbd>\n            {labels.open}\n          </span>\n        </div>\n      </div>\n    </article>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "meta": {
    "tier": "free",
    "added": "2026-10-07",
    "usedIn": [],
    "props": [
      {
        "name": "name",
        "type": "string",
        "default": null,
        "required": true,
        "description": "The item's name (a slug like \"flux-image\"). It is the link text and, without a poster, the poster."
      },
      {
        "name": "href",
        "type": "string",
        "default": null,
        "required": true,
        "description": "Where the card leads. Always a real URL, so the card works without JS and opens in a new tab on Cmd/Ctrl click."
      },
      {
        "name": "description",
        "type": "string",
        "default": null,
        "required": false,
        "description": "One or two sentences; clamped to two lines."
      },
      {
        "name": "descriptionLang",
        "type": "string",
        "default": null,
        "required": false,
        "description": "The language of the description, when it differs from the page (e.g. English descriptions on a Danish page)."
      },
      {
        "name": "tier",
        "type": "'free' | 'pro'",
        "default": "'free'",
        "required": false,
        "description": "Which tier badge to show."
      },
      {
        "name": "category",
        "type": "string",
        "default": null,
        "required": false,
        "description": "A category label under the description, e.g. \"surfaces\"."
      },
      {
        "name": "isNew",
        "type": "boolean",
        "default": "false",
        "required": false,
        "description": "Marks the item as recently added."
      },
      {
        "name": "poster",
        "type": "string",
        "default": null,
        "required": false,
        "description": "Poster image (16:10). Decorative: the name is the link text. Without one, or if it fails, the card draws its own."
      },
      {
        "name": "loop",
        "type": "string",
        "default": null,
        "required": false,
        "description": "A short muted video that loops on hover and focus. Fetched on first hover/focus; never plays under reduced motion."
      },
      {
        "name": "onOpen",
        "type": "(event: MouseEvent<HTMLAnchorElement>) => void",
        "default": null,
        "required": false,
        "description": "Called on a plain click or Enter instead of navigating, e.g. to open a detail view. Modified clicks keep the link."
      },
      {
        "name": "headingLevel",
        "type": "2 | 3 | 4 | 5 | 6",
        "default": "3",
        "required": false,
        "description": "The heading level of the name."
      },
      {
        "name": "labels",
        "type": "Partial<GalleryCardLabels>",
        "default": null,
        "required": false,
        "description": "Every visible string, for other languages."
      },
      {
        "name": "className",
        "type": "string",
        "default": null,
        "required": false,
        "description": null
      }
    ],
    "a11y": [
      {
        "topic": "keyboard",
        "text": "One Tab stop per card: the name is the link and covers the card. Focus starts the loop like hover does. With onOpen, a plain click or Enter opens the detail view; Cmd, Ctrl and middle click keep the link.",
        "keys": [
          "Tab",
          "Enter"
        ]
      },
      {
        "topic": "screen-reader",
        "text": "The link is described by the tier badge and the description (aria-describedby). The video and the Enter hint are aria-hidden."
      },
      {
        "topic": "motion",
        "text": "The loop never plays under reduced motion; the poster stays."
      }
    ]
  },
  "categories": [
    "surfaces"
  ],
  "type": "registry:component"
}