{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glyph-card",
  "type": "registry:ui",
  "title": "Glyph Card",
  "description": "A hairline card whose background resolves into tiny plus marks on hover, gathering inside the glyph. Draws itself from a single SVG path on a 24x24 grid.",
  "author": "Aditya Ojha <https://akoder.xyz>",
  "categories": [
    "card",
    "hover",
    "svg",
    "interactive"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [],
  "meta": {
    "docs": "https://akoder.xyz/components/glyph-card",
    "source": "https://github.com/AdityaKodez/adityaojha/blob/main/components/ui/glyph-card.tsx"
  },
  "files": [
    {
      "path": "components/ui/glyph-card.tsx",
      "content": "\"use client\";\n\nimport { ArrowUpRight } from \"lucide-react\";\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\nimport { useId, useState, type CSSProperties } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst FIELD_BOX = 100;\nconst GLYPH_GRID = 24;\nconst DEFAULT_GLYPH_SIZE = 62;\n\n/**\n * Plus marks and the grid they sit on. `TILE` is the pitch between marks and\n * `PLUS_ARM` is half the length of one, so the pair controls how dense and how\n * heavy the field reads. Smaller arms with a tighter tile give a finer grain.\n */\nconst TILE = 2.1;\nconst PLUS_ARM = 0.4;\nconst PLUS_STROKE = 0.18;\nconst PLUS_CENTER = TILE / 2;\nconst PLUS_PATH = `M${PLUS_CENTER - PLUS_ARM} ${PLUS_CENTER}h${PLUS_ARM * 2}M${PLUS_CENTER} ${PLUS_CENTER - PLUS_ARM}v${PLUS_ARM * 2}`;\n\n/**\n * Hover defocus for the resting field.\n *\n * The blur is applied to a `<g>` inside the SVG, so the radius is in user\n * units, not CSS pixels: the field is a 100-unit viewBox stretched across the\n * card, so one unit is roughly 3.8px on a 380px card and `FIELD_BLUR` reads as\n * under 2px on screen.\n *\n * Blur averages pixels rather than moving them, so a mark thinner than the\n * radius loses its peak and fades to nothing. At the resting stroke of 0.18\n * units a defocused field does not go soft, it disappears. The stroke and the\n * opacity therefore grow together with the blur, which is what keeps the haze\n * visible instead of empty. Widen `FIELD_BLUR` and both have to follow.\n */\nconst FIELD_BLUR = 0.44;\nconst PLUS_STROKE_DEFOCUSED = 0.34;\nconst REST_OPACITY = 1;\nconst REST_OPACITY_DEFOCUSED = 0.5;\n\nconst FIELD_ORIGIN: CSSProperties = {\n  transformBox: \"view-box\",\n  transformOrigin: `${FIELD_BOX / 2}px ${FIELD_BOX / 2}px`,\n};\n\nconst PLUS_ORIGIN: CSSProperties = {\n  transformOrigin: `${PLUS_CENTER}px ${PLUS_CENTER}px`,\n};\n\nconst FOCUS: Transition = {\n  duration: 0.24,\n  ease: [0.22, 1, 0.36, 1],\n};\n\nconst REDUCED: Transition = { duration: 0 };\n\n/**\n * Published-file fallbacks. `--primary` and `--card-foreground` are this site's\n * tokens, so a consumer project without them would otherwise render the artwork\n * in an invalid color. Literals mirror the light-theme token values.\n */\nconst FALLBACK_ACCENT = \"#1447E6\";\nconst FALLBACK_CARD_FOREGROUND = \"#0A0A0A\";\n\nexport interface GlyphCardProps {\n  /** Small mono label in the top-left, for example \"Runtime\". */\n  eyebrow: string;\n  title: string;\n  subtitle?: string;\n  /** Makes the whole card a link. Omit for a static card. */\n  href?: string;\n  /** Link target attribute, for example \"_blank\". */\n  target?: string;\n  /** Link rel attribute, for example \"noopener noreferrer\". */\n  rel?: string;\n  /** Explicit accessible label for the card link. */\n  \"aria-label\"?: string;\n  /** SVG path data authored on a 24x24 grid. */\n  glyph: string;\n  /**\n   * Shifts the glyph inside the 24x24 viewBox, in grid units.\n   *\n   * Most brand marks are already centered on the grid and need nothing. A mark\n   * that is not, for example one whose art only occupies the bottom-right\n   * quadrant, ends up off-center inside the card. Rather than rewriting the path\n   * coordinates, pass the offset that recenters it: a mark whose bounding box\n   * runs x 10..22 and y 8..24 has a center of (16, 16), so it wants\n   * `glyphOffset={[-4, -4]}` to land on (12, 12).\n   */\n  glyphOffset?: readonly [number, number];\n  /**\n   * Size of the glyph viewport inside the 100x100 field.\n   */\n  glyphSize?: number;\n  /** Accent for the artwork and link arrow. */\n  accent?: string;\n  className?: string;\n}\n\nexport function GlyphCard({\n  eyebrow,\n  title,\n  subtitle,\n  href,\n  target,\n  rel,\n  \"aria-label\": ariaLabel,\n  glyph,\n  glyphOffset,\n  glyphSize = DEFAULT_GLYPH_SIZE,\n  accent = `var(--primary, ${FALLBACK_ACCENT})`,\n  className,\n}: GlyphCardProps) {\n  const uid = `gc-${useId().replace(/:/g, \"-\")}`;\n  const restPatternId = `${uid}-rp`;\n  const hoverPatternId = `${uid}-hp`;\n  const outsideMaskId = `${uid}-om`;\n  const insideMaskId = `${uid}-im`;\n\n  const [isActive, setIsActive] = useState(false);\n  const reduceMotion = useReducedMotion();\n  const focus = reduceMotion ? REDUCED : FOCUS;\n  const glyphScale = reduceMotion ? 1 : 0.965;\n  // A quarter turn would look identical because a plus is 90-degree symmetric.\n  // Forty-five degrees makes the background marks resolve into subtle x shapes.\n  const plusRotation = reduceMotion || !isActive ? 0 : 45;\n\n  /*\n   * The glyph sits in a `glyphSize` box centered inside the 100x100 field, so\n   * the inset is half the leftover space. Both masks share it, which is what\n   * keeps the resting field and the hover field cut to the same shape.\n   */\n  const glyphInset = (FIELD_BOX - glyphSize) / 2;\n\n  /*\n   * Applied on a wrapping <g> so both masks move together. The transform is in\n   * grid units, which are the units the glyph author already knows.\n   */\n  const glyphTransform = glyphOffset\n    ? `translate(${glyphOffset[0]} ${glyphOffset[1]})`\n    : undefined;\n\n  /*\n   * The SVG covers the complete card. preserveAspectRatio=\"none\" lets the plus\n   * field reach every edge, while each mask contains a nested 24x24 SVG that\n   * preserves the glyph proportions inside a centered responsive viewport.\n   */\n  const field = (\n    <svg\n      viewBox={`0 0 ${FIELD_BOX} ${FIELD_BOX}`}\n      preserveAspectRatio=\"none\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className=\"pointer-events-none absolute inset-0 z-0 block h-full w-full\"\n    >\n      <motion.g\n        animate={{ filter: isActive ? `blur(${FIELD_BLUR}px)` : \"blur(0px)\" }}\n        transition={focus}\n      >\n        <rect\n          width={FIELD_BOX}\n          height={FIELD_BOX}\n          fill={`url(#${restPatternId})`}\n          mask={`url(#${outsideMaskId})`}\n        />\n      </motion.g>\n\n      <motion.g\n        style={FIELD_ORIGIN}\n        animate={{\n          opacity: isActive ? 1 : 0,\n          scale: isActive && !reduceMotion ? 1.01 : glyphScale,\n        }}\n        transition={focus}\n      >\n        <rect\n          width={FIELD_BOX}\n          height={FIELD_BOX}\n          fill={`url(#${hoverPatternId})`}\n          mask={`url(#${insideMaskId})`}\n        />\n      </motion.g>\n\n      <defs>\n        <pattern\n          id={restPatternId}\n          width={TILE}\n          height={TILE}\n          patternUnits=\"userSpaceOnUse\"\n        >\n          {/*\n            No filter here. A filter on this path runs inside the pattern tile,\n            which is only TILE units wide and clips its own content, so the blur\n            is thrown away at the tile edge and the field flattens to nothing.\n            The blur belongs on the group that paints the field, above.\n          */}\n          <motion.path\n            d={PLUS_PATH}\n            fill=\"none\"\n            stroke={`color-mix(in oklab, ${accent} 44%, var(--card-foreground, ${FALLBACK_CARD_FOREGROUND}))`}\n            strokeWidth={PLUS_STROKE}\n            strokeLinecap=\"round\"\n            style={PLUS_ORIGIN}\n            animate={{\n              rotate: plusRotation,\n              opacity: isActive ? REST_OPACITY_DEFOCUSED : REST_OPACITY,\n              strokeWidth: isActive ? PLUS_STROKE_DEFOCUSED : PLUS_STROKE,\n            }}\n            transition={focus}\n          />\n        </pattern>\n\n        <pattern\n          id={hoverPatternId}\n          width={TILE}\n          height={TILE}\n          patternUnits=\"userSpaceOnUse\"\n        >\n          <path\n            d={PLUS_PATH}\n            fill=\"none\"\n            stroke={accent}\n            strokeWidth={PLUS_STROKE * 1.35}\n            strokeLinecap=\"round\"\n          />\n        </pattern>\n\n        <mask\n          id={outsideMaskId}\n          maskUnits=\"userSpaceOnUse\"\n          x=\"0\"\n          y=\"0\"\n          width={FIELD_BOX}\n          height={FIELD_BOX}\n        >\n          <rect width={FIELD_BOX} height={FIELD_BOX} fill=\"#fff\" />\n          <svg\n            x={glyphInset}\n            y={glyphInset}\n            width={glyphSize}\n            height={glyphSize}\n            viewBox={`0 0 ${GLYPH_GRID} ${GLYPH_GRID}`}\n            preserveAspectRatio=\"xMidYMid meet\"\n          >\n            <g transform={glyphTransform}>\n              <path d={glyph} fill=\"#000\" />\n            </g>\n          </svg>\n        </mask>\n\n        <mask\n          id={insideMaskId}\n          maskUnits=\"userSpaceOnUse\"\n          x=\"0\"\n          y=\"0\"\n          width={FIELD_BOX}\n          height={FIELD_BOX}\n        >\n          <svg\n            x={glyphInset}\n            y={glyphInset}\n            width={glyphSize}\n            height={glyphSize}\n            viewBox={`0 0 ${GLYPH_GRID} ${GLYPH_GRID}`}\n            preserveAspectRatio=\"xMidYMid meet\"\n          >\n            <g transform={glyphTransform}>\n              <path d={glyph} fill=\"#fff\" />\n            </g>\n          </svg>\n        </mask>\n      </defs>\n    </svg>\n  );\n\n  const isExternal =\n    target === \"_blank\" ||\n    (typeof href === \"string\" &&\n      (href.startsWith(\"http://\") || href.startsWith(\"https://\")));\n\n  const content = (\n    <>\n      <div className=\"relative z-10 flex min-w-0 items-start justify-between gap-3\">\n        <span className=\"min-w-0 rounded-sm bg-card/90 px-1.5 py-0.5 font-mono text-xs text-muted-foreground backdrop-blur-[1px] [overflow-wrap:anywhere]\">\n          {eyebrow}\n        </span>\n        {href ? (\n          <span className=\"grid size-6 shrink-0 place-items-center rounded-full bg-card/90 backdrop-blur-[1px]\">\n            <ArrowUpRight\n              aria-hidden=\"true\"\n              className=\"size-3.5 transition-transform duration-[220ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:group-hover:-translate-y-0.5 motion-safe:group-hover:translate-x-0.5 motion-safe:group-focus-visible:-translate-y-0.5 motion-safe:group-focus-visible:translate-x-0.5\"\n            />\n            {isExternal && (\n              <span className=\"sr-only\">(opens external link)</span>\n            )}\n          </span>\n        ) : null}\n      </div>\n\n      <div aria-hidden=\"true\" className=\"min-h-40 sm:min-h-48\" />\n\n      <div className=\"relative z-10 grid min-w-0 gap-1 [overflow-wrap:anywhere]\">\n        <h3 className=\"text-base leading-snug font-medium tracking-tight text-card-foreground\">\n          {title}\n        </h3>\n        {subtitle ? (\n          <p className=\"text-sm leading-relaxed text-muted-foreground\">{subtitle}</p>\n        ) : null}\n      </div>\n    </>\n  );\n\n  const rootClass = cn(\n    \"group relative grid h-full w-full min-w-0 grid-rows-[auto_1fr_auto] gap-4 overflow-hidden rounded-none\",\n    \"bg-background p-2 ring-1 ring-inset ring-border sm:p-5\",\n    // Mirrors the site's `.micro-transition`, inlined so the published file\n    // carries no dependency on a class that only exists in this project.\n    \"transition-[transform,background-color,border-color,color,opacity,box-shadow]\",\n    \"duration-[220ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none\",\n    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n    className,\n  );\n\n  const interactionProps = {\n    onHoverStart: () => setIsActive(true),\n    onHoverEnd: () => setIsActive(false),\n    onFocus: () => setIsActive(true),\n    onBlur: () => setIsActive(false),\n  };\n\n  const style = { color: accent };\n\n  if (href) {\n    const computedRel =\n      rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n    return (\n      <motion.a\n        href={href}\n        target={target}\n        rel={computedRel}\n        aria-label={ariaLabel}\n        className={rootClass}\n        style={style}\n        {...interactionProps}\n      >\n        {field}\n        {content}\n      </motion.a>\n    );\n  }\n\n  return (\n    <motion.div\n      className={cn(rootClass, \"cursor-default\")}\n      style={style}\n      {...interactionProps}\n    >\n      {field}\n      {content}\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "## Installation\n\n```bash\nnpx shadcn@latest add @akoder/glyph-card\n```\n\n## Usage\n\n```tsx\nimport { GlyphCard } from \"@/components/ui/glyph-card\";\n\nconst NEXTJS_GLYPH =\n  \"M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z\";\n\nexport function Stack() {\n  return (\n    <GlyphCard\n      eyebrow=\"Framework\"\n      title=\"Next.js\"\n      subtitle=\"The React framework\"\n      href=\"https://nextjs.org\"\n      glyph={NEXTJS_GLYPH}\n    />\n  );\n}\n```\n\n`glyph` is SVG path data authored on a 24x24 grid. Omit `href` for a static card, pass `accent` to tint the artwork, and use `glyphOffset` in grid units when a mark is drawn off-center."
}