{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "profile-card",
  "type": "registry:ui",
  "title": "Profile Card",
  "description": "A social profile card with accent-tinted cover art, verified badge, follow toggle, stats and link.",
  "files": [
    {
      "path": "src/registry/ui/profile-card.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useId, useRef, useState } from \"react\";\nimport Image from \"next/image\";\nimport { BadgeCheck, Check, Link2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ProfileImage = { src: string; alt: string };\nexport type ProfileStat = { label: React.ReactNode; value: React.ReactNode };\nexport type ProfileLink = { href: string; label: React.ReactNode };\n\nexport type ProfileCardProps = Omit<React.ComponentProps<\"article\">, \"title\"> & {\n  name: React.ReactNode;\n  handle?: React.ReactNode;\n  verified?: boolean;\n  /** An image, or any node such as an inline SVG logo. */\n  avatar?: ProfileImage | React.ReactNode;\n  /** Cover image. Leave it out to use the animated pixel field, tinted by `accent`. */\n  cover?: ProfileImage;\n  bio?: React.ReactNode;\n  stats?: ProfileStat[];\n  link?: ProfileLink;\n  /** Any CSS color. Tints the cover, badge, follow button and link icon. */\n  accent?: string;\n  /** Follow state (controlled). */\n  following?: boolean;\n  defaultFollowing?: boolean;\n  onFollowingChange?: (following: boolean) => void;\n  /** Set to false to hide the follow button. */\n  showFollow?: boolean;\n  followLabel?: React.ReactNode;\n  followingLabel?: React.ReactNode;\n};\n\nconst isImage = (value: unknown): value is ProfileImage =>\n  typeof value === \"object\" &&\n  value !== null &&\n  !isValidElement(value) &&\n  \"src\" in value;\n\nexport function ProfileCard({\n  name,\n  handle,\n  verified = false,\n  avatar,\n  cover,\n  bio,\n  stats,\n  link,\n  accent = \"#0A6EFF\",\n  following,\n  defaultFollowing = false,\n  onFollowingChange,\n  showFollow = true,\n  followLabel = \"Follow\",\n  followingLabel = \"Following\",\n  className,\n  style,\n  ...props\n}: ProfileCardProps) {\n  const nameId = useId();\n  const [uncontrolledFollowing, setUncontrolledFollowing] = useState(defaultFollowing);\n  const isFollowing = following ?? uncontrolledFollowing;\n  const toggleFollowing = () => {\n    const next = !isFollowing;\n    if (following === undefined) setUncontrolledFollowing(next);\n    onFollowingChange?.(next);\n  };\n\n  return (\n    <article\n      data-slot=\"profile-card\"\n      aria-labelledby={nameId}\n      style={\n        {\n          \"--profile-accent\": accent,\n          \"--profile-cover\": `color-mix(in oklab, ${accent} 7%, white)`,\n          ...style,\n        } as React.CSSProperties\n      }\n      className={cn(\n        \"group w-full max-w-sm rounded-[2rem] bg-neutral-900 p-2.5 text-white shadow-2xl shadow-black/25\",\n        className\n      )}\n      {...props}\n    >\n      <div className=\"relative isolate aspect-[10/11] overflow-hidden rounded-[1.5rem] bg-[var(--profile-cover)]\">\n        {isImage(cover) ? (\n          <Image\n            src={cover.src}\n            alt={cover.alt}\n            fill\n            sizes=\"384px\"\n            className=\"-z-10 object-cover\"\n          />\n        ) : (\n          <PixelCover accent={accent} />\n        )}\n        {/* Calms the bottom of the cover so the name and button keep their contrast. */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 -z-10 h-1/2 bg-[linear-gradient(to_top,var(--profile-cover)_25%,transparent)]\"\n        />\n\n        <div className=\"absolute inset-x-3 bottom-3 flex items-end gap-3.5\">\n          {avatar != null && (\n            <div className=\"relative size-[5.5rem] shrink-0 overflow-hidden rounded-[1.25rem] bg-neutral-900 shadow-xl shadow-black/30 ring-1 ring-black/5\">\n              {isImage(avatar) ? (\n                <Image src={avatar.src} alt={avatar.alt} fill sizes=\"88px\" className=\"object-cover\" />\n              ) : (\n                avatar\n              )}\n            </div>\n          )}\n\n          <div className=\"min-w-0 pb-0.5 text-neutral-900\">\n            <h3\n              id={nameId}\n              className=\"flex items-center gap-1.5 text-2xl leading-tight font-semibold tracking-[-0.03em]\"\n            >\n              <span className=\"truncate\">{name}</span>\n              {verified && (\n                <BadgeCheck\n                  role=\"img\"\n                  aria-label=\"Verified account\"\n                  className=\"size-6 shrink-0 fill-[var(--profile-accent)] text-white\"\n                />\n              )}\n            </h3>\n            {handle && <p className=\"truncate text-sm text-neutral-900/55\">{handle}</p>}\n\n            {showFollow && (\n              <button\n                type=\"button\"\n                aria-pressed={isFollowing}\n                onClick={toggleFollowing}\n                className={cn(\n                  \"mt-3 inline-grid h-10 cursor-pointer place-items-center rounded-full px-6 text-[0.9375rem] font-medium outline-none transition-[background-color,color,box-shadow,filter,transform] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--profile-cover)] active:scale-[0.97] motion-reduce:active:scale-100\",\n                  isFollowing\n                    ? \"bg-white/80 text-neutral-900 ring-1 ring-neutral-900/15 backdrop-blur hover:bg-white\"\n                    : \"bg-[var(--profile-accent)] text-white shadow-lg shadow-[color-mix(in_oklab,var(--profile-accent)_40%,transparent)] hover:brightness-110\"\n                )}\n              >\n                {/* Both labels share one cell, so the button keeps the wider width and never jumps. */}\n                <FollowLabel visible={!isFollowing}>{followLabel}</FollowLabel>\n                <FollowLabel visible={isFollowing}>\n                  <Check aria-hidden className=\"size-4\" />\n                  {followingLabel}\n                </FollowLabel>\n              </button>\n            )}\n          </div>\n        </div>\n      </div>\n\n      {(bio || stats?.length || link) && (\n        <div className=\"space-y-4 px-3.5 pt-5 pb-4\">\n          {bio && <p className=\"text-[0.9375rem] leading-snug text-white/90 text-pretty\">{bio}</p>}\n\n          {stats && stats.length > 0 && (\n            <dl className=\"flex flex-wrap gap-x-7 gap-y-1 text-[0.9375rem]\">\n              {stats.map((stat, i) => (\n                // Value before label visually, but the dt still names the dd for screen readers.\n                <div key={i} className=\"flex flex-row-reverse gap-1\">\n                  <dt className=\"text-white/50\">{stat.label}</dt>\n                  <dd className=\"font-medium tabular-nums\">{stat.value}</dd>\n                </div>\n              ))}\n            </dl>\n          )}\n\n          {link && (\n            <a\n              href={link.href}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-2 rounded-sm text-[0.9375rem] outline-none transition-colors hover:text-[var(--profile-accent)] focus-visible:ring-2 focus-visible:ring-white/60\"\n            >\n              <Link2 aria-hidden className=\"size-4 -rotate-45 text-[var(--profile-accent)]\" />\n              {link.label}\n            </a>\n          )}\n        </div>\n      )}\n    </article>\n  );\n}\n\nfunction FollowLabel({ visible, children }: { visible: boolean; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!visible}\n      className={cn(\n        \"inline-flex items-center gap-1.5 transition-[opacity,filter] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] [grid-area:1/1]\",\n        visible ? \"opacity-100\" : \"opacity-0 blur-[2px] motion-reduce:blur-none\"\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nconst CELL = 10;\nconst GAP = 3;\nconst PITCH = CELL + GAP;\nconst FPS = 30;\nconst TRAIL_LIFE = 700;\nconst TRAIL_RADIUS = 16;\n\n// Stable per-cell noise in [0, 1), so cells keep their identity between frames.\nconst hash = (x: number, y: number, salt = 0) => {\n  let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + salt) | 0;\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n};\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\n\n// How far each column hangs down, as a share of the rows: slow sine waves, like\n// an upside-down equalizer, so the bottom of the cover stays calm.\nconst columnLevel = (column: number, time: number) =>\n  clamp(\n    0.4 +\n      0.12 * Math.sin(column * 0.33 + time * 0.6) +\n      0.08 * Math.sin(column * 0.87 - time * 0.9 + 1.3) +\n      0.05 * Math.sin(column * 1.7 + time * 1.5),\n    0.15,\n    0.72\n  );\n\n// A pixel field in the accent color, with a cursor trail on hover.\nfunction PixelCover({ accent: accentProp }: { accent: string }) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    const host = canvas?.parentElement;\n    if (!canvas || !ctx || !host) return;\n\n    const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduceMotion = motionQuery.matches;\n    let accent = \"\";\n    let width = 0;\n    let height = 0;\n    let columns = 0;\n    let rows = 0;\n    let offsetX = 0;\n    let frameId = 0;\n    let lastFrame = 0;\n    let isVisible = false;\n    let trail = new Map<number, number>();\n    let lastPointer: { x: number; y: number } | null = null;\n\n    const draw = (now: number) => {\n      const time = now / 1000;\n      const flickerStep = Math.floor(time * 0.8);\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = accent;\n\n      for (let column = 0; column < columns; column++) {\n        const level = columnLevel(column, time) * rows;\n        for (let row = 0; row < rows; row++) {\n          const depth = level - row;\n          const isFloater = depth <= -1;\n          const presence = isFloater ? 0.03 : depth > 1.5 ? 0.92 : 0.45 + depth * 0.3;\n          const roll = isFloater\n            ? hash(column, row * 31 + flickerStep, 7)\n            : hash(column, row);\n          if (roll >= presence) continue;\n\n          const isSpark = hash(column, row, 11) < 0.04;\n          let alpha = isFloater\n            ? 0.25\n            : clamp(1 - (row / rows) * 1.3, 0.12, 1) * (0.45 + 0.55 * hash(column, row, 3));\n          if (hash(column, row, 19) < 0.16) alpha *= 0.35;\n          ctx.globalAlpha = isSpark ? 1 : alpha;\n          ctx.fillRect(offsetX + column * PITCH, row * PITCH, CELL, CELL);\n        }\n      }\n\n      for (const [key, born] of trail) {\n        const age = now - born;\n        if (age >= TRAIL_LIFE) {\n          trail.delete(key);\n          continue;\n        }\n        const fade = 1 - age / TRAIL_LIFE;\n        ctx.globalAlpha = fade * fade * 0.85;\n        ctx.fillRect(offsetX + (key % columns) * PITCH, Math.floor(key / columns) * PITCH, CELL, CELL);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const loop = (now: number) => {\n      frameId = requestAnimationFrame(loop);\n      if (now - lastFrame < 1000 / FPS) return;\n      lastFrame = now;\n      draw(now);\n    };\n    const start = () => {\n      if (!reduceMotion && isVisible && !frameId) frameId = requestAnimationFrame(loop);\n    };\n    const stop = () => {\n      cancelAnimationFrame(frameId);\n      frameId = 0;\n    };\n\n    const resize = () => {\n      const dpr = window.devicePixelRatio || 1;\n      width = canvas.clientWidth;\n      height = canvas.clientHeight;\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      // The canvas sits under text-[var(--profile-accent)], so its color is the accent.\n      // Read it here rather than every frame; a new accent re-renders and resizes rarely.\n      accent = getComputedStyle(canvas).color;\n      columns = Math.floor((width + GAP) / PITCH);\n      rows = Math.ceil(height / PITCH);\n      offsetX = Math.floor((width - (columns * PITCH - GAP)) / 2);\n      // Trail keys encode the column count, so they're stale after a resize.\n      trail = new Map();\n      draw(performance.now());\n    };\n\n    // Light the cells near one point; revisiting a cell restarts its fade.\n    const paint = (x: number, y: number, now: number) => {\n      const minColumn = Math.max(0, Math.floor((x - TRAIL_RADIUS - offsetX) / PITCH));\n      const maxColumn = Math.min(columns - 1, Math.ceil((x + TRAIL_RADIUS - offsetX) / PITCH));\n      const minRow = Math.max(0, Math.floor((y - TRAIL_RADIUS) / PITCH));\n      const maxRow = Math.min(rows - 1, Math.ceil((y + TRAIL_RADIUS) / PITCH));\n      for (let column = minColumn; column <= maxColumn; column++) {\n        for (let row = minRow; row <= maxRow; row++) {\n          const dx = offsetX + column * PITCH + CELL / 2 - x;\n          const dy = row * PITCH + CELL / 2 - y;\n          if (Math.hypot(dx, dy) <= TRAIL_RADIUS) trail.set(row * columns + column, now);\n        }\n      }\n    };\n\n    const handlePointerMove = (event: PointerEvent) => {\n      if (event.pointerType !== \"mouse\" || reduceMotion) return;\n      const rect = canvas.getBoundingClientRect();\n      const next = { x: event.clientX - rect.left, y: event.clientY - rect.top };\n      const now = performance.now();\n      // Fast moves skip cells between events, so fill in the path between them.\n      const from = lastPointer ?? next;\n      const steps = Math.max(1, Math.ceil(Math.hypot(next.x - from.x, next.y - from.y) / (PITCH / 2)));\n      for (let i = 1; i <= steps; i++) {\n        const t = i / steps;\n        paint(from.x + (next.x - from.x) * t, from.y + (next.y - from.y) * t, now);\n      }\n      lastPointer = next;\n    };\n    const handlePointerLeave = () => {\n      lastPointer = null;\n    };\n\n    // Follow the OS setting live: freeze on a still frame when reduced motion turns on.\n    const handleMotionChange = () => {\n      reduceMotion = motionQuery.matches;\n      if (reduceMotion) {\n        stop();\n        trail = new Map();\n        draw(performance.now());\n      } else {\n        start();\n      }\n    };\n    motionQuery.addEventListener(\"change\", handleMotionChange);\n\n    const resizeObserver = new ResizeObserver(resize);\n    resizeObserver.observe(canvas);\n    // Only animate while the card is on screen.\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      isVisible = entry.isIntersecting;\n      if (isVisible) start();\n      else stop();\n    });\n    intersectionObserver.observe(canvas);\n    host.addEventListener(\"pointermove\", handlePointerMove);\n    host.addEventListener(\"pointerleave\", handlePointerLeave);\n\n    return () => {\n      stop();\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      host.removeEventListener(\"pointermove\", handlePointerMove);\n      host.removeEventListener(\"pointerleave\", handlePointerLeave);\n      motionQuery.removeEventListener(\"change\", handleMotionChange);\n    };\n    // Re-run when the accent changes so the cached canvas colour stays current.\n  }, [accentProp]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden\n      className=\"absolute inset-0 -z-20 block size-full text-[var(--profile-accent)]\"\n    />\n  );\n}\n"
    }
  ],
  "dependencies": [
    "lucide-react"
  ]
}
