{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keycard",
  "title": "Keycard",
  "description": "An API key on a plastic card that slides into a brass reader. For bring-your-own-key and credential screens.",
  "dependencies": [
    "@base-ui/react",
    "cn"
  ],
  "registryDependencies": [
    "https://bevel-ui.vercel.app/r/bevel.json",
    "https://bevel-ui.vercel.app/r/payment-card.json"
  ],
  "files": [
    {
      "path": "registry/bevel/ui/keycard.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\nimport { PaymentCard, type PaymentCardProps } from \"@/components/ui/payment-card\"\n\n/** An access key as a plastic card with a magnetic stripe. A PaymentCard underneath. */\nfunction Keycard({ className, children, tone = \"graphite\", ...props }: PaymentCardProps) {\n  return (\n    <PaymentCard\n      tone={tone}\n      data-keycard=\"\"\n      className={cn(\"flex-col items-stretch gap-1 pt-3\", className)}\n      {...props}\n    >\n      <span aria-hidden data-slot=\"keycard-stripe\" className=\"absolute inset-x-0 bottom-[18%] h-[16%] bg-(--keycard-stripe)\" />\n      {children}\n    </PaymentCard>\n  )\n}\n\nfunction KeycardLabel({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"keycard-label\"\n      className={cn(\n        \"text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-print) uppercase\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype KeycardKeyProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The full key. Only its prefix and last four are shown unless revealed. */\n  value: string\n  revealed?: boolean\n}\n\n/** The key itself, masked to its prefix and last four characters. */\nfunction KeycardKey({ value, revealed = false, className, ...props }: KeycardKeyProps) {\n  const prefix = value.match(/^[a-z]+[-_]/i)?.[0] ?? \"\"\n  const last4 = value.slice(-4)\n  return (\n    <span\n      data-slot=\"keycard-key\"\n      data-revealed={revealed ? \"\" : undefined}\n      className={cn(\"font-mono text-(length:--bevel-fine) break-all\", className)}\n      {...props}\n    >\n      {revealed ? (\n        value\n      ) : (\n        <>\n          {prefix}\n          <span aria-hidden>••••••••</span>\n          <span className=\"sr-only\">ending in </span>\n          {last4}\n        </>\n      )}\n    </span>\n  )\n}\n\ntype KeycardSlotProps = React.ComponentProps<\"div\"> & {\n  /** The card is in the reader. */\n  inserted?: boolean\n  /** Text on the reader plate. Defaults describe whether a key is in. */\n  status?: React.ReactNode\n}\n\n/** A brass card reader. Put a Keycard inside; set `inserted` and it slides into the slot. */\nfunction KeycardSlot({ className, inserted = false, status, children, ...props }: KeycardSlotProps) {\n  return (\n    <div\n      data-slot=\"keycard-slot\"\n      data-inserted={inserted ? \"\" : undefined}\n      className={cn(\"relative w-full max-w-xs pb-(--keycard-plate-height)\", className)}\n      {...props}\n    >\n      {/* The reader hides whatever part of the card is inside it. */}\n      <div className=\"keycard-mouth\">\n        <div\n          className={cn(\n            \"transition-[translate] duration-(--bevel-duration-move) ease-(--bevel-ease-in-out) motion-reduce:transition-none\",\n            inserted && \"translate-y-(--keycard-insert)\"\n          )}\n        >\n          {children}\n        </div>\n      </div>\n      <div\n        data-slot=\"keycard-reader\"\n        className=\"bevel-metal bevel-object absolute inset-x-0 bottom-0 z-10 flex h-(--keycard-plate-height) flex-col justify-end rounded-(--bevel-radius-sm) px-4 pb-3\"\n      >\n        <span aria-hidden className=\"bevel-recessed absolute inset-x-3 top-2 h-1.5 rounded-full\" />\n        <span\n          aria-live=\"polite\"\n          className=\"bevel-deboss flex items-center gap-1.5 text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-print) text-(--bevel-ivory-foreground) uppercase\"\n        >\n          <span aria-hidden className=\"bevel-led size-1.5 rounded-full\" />\n          {status ?? (inserted ? \"Key in use\" : \"No key inserted\")}\n        </span>\n      </div>\n    </div>\n  )\n}\n\n// A swipe counts once the card has travelled this share of the channel,\n// or if it's flicked faster than this (px/ms), however short the drag.\nconst SWIPE_DISTANCE = 0.6\nconst SWIPE_VELOCITY = 0.11\n// Resistance past either end of the channel.\nconst FRICTION = 0.2\n\n/**\n * Run `then` when the element's transition ends. Falls back to a timer so a\n * hidden tab (where transitions don't run) can't leave the reader stuck.\n */\nfunction afterTransition(element: HTMLElement, animated: boolean, then: () => void) {\n  if (!animated) return then()\n  let done = false\n  const finish = () => {\n    if (done) return\n    done = true\n    then()\n  }\n  element.addEventListener(\"transitionend\", finish, { once: true })\n  const seconds = parseFloat(getComputedStyle(element).transitionDuration) || 0\n  window.setTimeout(finish, seconds * 1000 + 50)\n}\n\ntype KeycardSwipeProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Called when a swipe completes, by drag, flick or keyboard. */\n  onSwipe?: () => void\n  /** Lights the reader lamp, e.g. once a key is accepted. */\n  accepted?: boolean\n  /** Text on the reader plate, announced politely. */\n  status?: React.ReactNode\n  /** The action, read before the card's own text: \"Swipe key: Your OpenAI key…\". */\n  label?: string\n  /** A Keycard. */\n  children: React.ReactNode\n}\n\n/**\n * A swipe reader: drag the card left to right through the channel. A long\n * enough drag or a quick flick completes the swipe; a short one springs\n * back. Enter or Space swipes from the keyboard.\n */\nfunction KeycardSwipe({\n  className,\n  onSwipe,\n  accepted = false,\n  status,\n  label = \"Swipe key\",\n  children,\n  ...props\n}: KeycardSwipeProps) {\n  const trackRef = React.useRef<HTMLDivElement>(null)\n  const cardRef = React.useRef<HTMLDivElement>(null)\n  const drag = React.useRef<{ pointerId: number; startX: number; startTime: number; x: number } | null>(null)\n  const busy = React.useRef(false)\n  const position = React.useRef(0)\n  const statusId = React.useId()\n\n  const travel = () => (trackRef.current?.clientWidth ?? 0) - (cardRef.current?.offsetWidth ?? 0)\n  const reducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n  // Position is written straight to the element so dragging never re-renders.\n  const moveTo = (x: number, transition: \"none\" | \"through\" | \"back\" | \"spring\", then?: () => void) => {\n    const card = cardRef.current\n    if (!card) return\n    const animated = transition !== \"none\" && !reducedMotion() && position.current !== x\n    position.current = x\n    card.style.transition = animated\n      ? {\n          through: \"translate var(--bevel-duration-move) var(--bevel-ease-out)\",\n          back: \"translate var(--bevel-duration-ambient) var(--bevel-ease-in-out)\",\n          spring: \"translate var(--bevel-duration-move) var(--bevel-ease-spring)\",\n          none: \"none\",\n        }[transition]\n      : \"none\"\n    card.style.translate = `${x}px 0`\n    if (then) afterTransition(card, animated, then)\n  }\n\n  const swipe = () => {\n    if (busy.current) return\n    busy.current = true\n    // The swipe is decided now; state doesn't wait for the animation.\n    onSwipe?.()\n    moveTo(travel(), \"through\", () =>\n      // Then the card comes back out of the reader, slower than it went through.\n      moveTo(0, \"back\", () => {\n        busy.current = false\n      })\n    )\n  }\n\n  return (\n    <div\n      ref={trackRef}\n      data-slot=\"keycard-swipe\"\n      data-accepted={accepted ? \"\" : undefined}\n      className={cn(\"relative w-full max-w-sm pb-[calc(var(--keycard-plate-height)-var(--keycard-swipe-overlap))]\", className)}\n      {...props}\n    >\n      <div\n        ref={cardRef}\n        role=\"button\"\n        tabIndex={0}\n        aria-describedby={statusId}\n        data-slot=\"keycard-swipe-card\"\n        className=\"relative w-[72%] cursor-grab touch-pan-y rounded-(--bevel-radius-md) outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:cursor-grabbing\"\n        onKeyDown={(event) => {\n          if (event.key !== \"Enter\" && event.key !== \" \") return\n          event.preventDefault()\n          swipe()\n        }}\n        onPointerDown={(event) => {\n          // One finger at a time; a second touch mid-drag is ignored.\n          if (drag.current || busy.current || event.button !== 0) return\n          event.currentTarget.setPointerCapture(event.pointerId)\n          drag.current = { pointerId: event.pointerId, startX: event.clientX, startTime: event.timeStamp, x: 0 }\n        }}\n        onPointerMove={(event) => {\n          const d = drag.current\n          if (!d || d.pointerId !== event.pointerId) return\n          const raw = event.clientX - d.startX\n          const max = travel()\n          // Friction past either end instead of a hard stop.\n          d.x = raw < 0 ? raw * FRICTION : raw > max ? max + (raw - max) * FRICTION : raw\n          moveTo(d.x, \"none\")\n        }}\n        onPointerUp={(event) => {\n          const d = drag.current\n          if (!d || d.pointerId !== event.pointerId) return\n          drag.current = null\n          const velocity = d.x / Math.max(1, event.timeStamp - d.startTime)\n          if (d.x >= travel() * SWIPE_DISTANCE || velocity > SWIPE_VELOCITY) swipe()\n          else moveTo(0, \"spring\")\n        }}\n        onPointerCancel={() => {\n          drag.current = null\n          moveTo(0, \"spring\")\n        }}\n      >\n        {/* The action leads the name; the card's visible text follows, so voice users can say what they see. */}\n        <span className=\"sr-only\">{label}: </span>\n        {children}\n      </div>\n      <div\n        data-slot=\"keycard-reader\"\n        className=\"bevel-metal bevel-object absolute inset-x-0 bottom-0 z-10 flex h-(--keycard-plate-height) flex-col justify-end rounded-(--bevel-radius-sm) px-4 pb-3\"\n      >\n        <span aria-hidden className=\"bevel-recessed absolute inset-x-0 top-0 h-2 rounded-t-(--bevel-radius-sm)\" />\n        <span\n          id={statusId}\n          aria-live=\"polite\"\n          className=\"bevel-deboss flex items-center gap-1.5 text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-print) text-(--bevel-ivory-foreground) uppercase\"\n        >\n          <span aria-hidden className=\"bevel-led size-1.5 rounded-full\" />\n          {status ?? (accepted ? \"Key accepted\" : \"Swipe the card to the right\")}\n        </span>\n      </div>\n    </div>\n  )\n}\n\nexport { Keycard, KeycardLabel, KeycardKey, KeycardSlot, KeycardSwipe }\nexport type { KeycardKeyProps, KeycardSlotProps, KeycardSwipeProps }\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    ":root": {
      "--keycard-stripe": "oklch(0.18 0.01 260)",
      "--keycard-plate-height": "3.75rem",
      "--keycard-insert": "62%",
      "--keycard-swipe-overlap": "1.25rem",
      "--keycard-slot-ok": "oklch(0.8 0.17 150)",
      "--keycard-slot-idle": "oklch(0.45 0.01 260)"
    },
    "[data-slot=\"keycard-slot\"]": {
      "--bevel-led": "var(--keycard-slot-idle)"
    },
    "[data-slot=\"keycard-slot\"][data-inserted], [data-slot=\"keycard-swipe\"][data-accepted]": {
      "--bevel-led": "var(--keycard-slot-ok)"
    },
    "[data-slot=\"keycard-swipe\"]": {
      "--bevel-led": "var(--keycard-slot-idle)"
    },
    "[data-slot=\"keycard-reader\"]": {
      "--bevel-material": "var(--bevel-brass)",
      "--bevel-material-edge": "var(--bevel-brass-edge)"
    },
    "@utility keycard-mouth": {
      "clip-path": "inset(-2rem -2rem calc(var(--keycard-plate-height) * -1) -2rem)"
    }
  },
  "categories": [
    "money"
  ],
  "type": "registry:ui"
}