Bevel

Keycard

An API key on a plastic card that slides into a brass reader. For bring-your-own-key and credential screens.

Bring your own key

Switch the breaker and the key goes into the reader: usage bills to it instead of credits.

Your OpenAI keysk-ending in 9e1f
Billed to credits
OffUse my key

Swipe to use

Drag the card through the reader, or flick it. A short drag springs back. Keyboard: focus the card, press Enter.

Swipe to use your key:
Your OpenAI keysk-ending in 9e1f
Swipe to bill to your key

Built from

Keycard is made of components that also work on their own.

Installation

Add with the CLI

  1. Once per project, add the Bevel registry to components.json.

    components.json
    {
      "registries": {
        "@bevel": "https://bevel-ui.vercel.app/r/{name}.json"
      }
    }
  2. Add Keycard. This brings in Payment card and the Bevel foundation, installs @base-ui/react and cn, and merges the styles into your global CSS.

    Terminal
    npx shadcn@latest add @bevel/keycard
Install manually
  1. Install the dependencies.

    Terminal
    pnpm add @base-ui/react cn
  2. Add the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.

    styles/bevel.css
    /*
     * Bevel foundation. Every component builds on these tokens and utilities;
     * component stylesheets only add their own palettes and shapes.
     *
     * Layers:
     *   1. Light     one source, above and slightly in front of the screen.
     *   2. Depth     named elevations built from the light: recessed, flush,
     *                raised, pressed, object.
     *   3. Material  a surface recipe (leather, plastic, metal) that reads
     *                --bevel-material and --bevel-material-edge.
     *   4. Motion    two curves, a spring, and durations named by purpose.
     *   5. Shape     radii, stitching, and the type scale for amounts.
     */
    
    :root {
      /* 1. Light */
      --bevel-highlight: oklch(1 0 0 / 0.22);
      --bevel-highlight-strong: oklch(1 0 0 / 0.35);
      --bevel-shade: oklch(0 0 0 / 0.32);
      --bevel-shade-strong: oklch(0 0 0 / 0.5);
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
      /* Stamped into a surface: shaded on the upper inner edge, lit below. */
      --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12);
    
      /* 3. Material defaults; components and variants override these. */
      --bevel-material: oklch(0.47 0.085 52);
      --bevel-material-edge: oklch(0.3 0.06 44);
      --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */
      --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
      --bevel-grain-size: 180px;
    
      /* 2. Depth */
      --bevel-depth-recessed:
        inset 0 1px 2px var(--bevel-shade-strong),
        0 1px 0 var(--bevel-highlight);
      --bevel-depth-flush:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade);
      --bevel-depth-raised:
        inset 0 1px 0 var(--bevel-highlight),
        0 1px 0 var(--bevel-material-edge),
        0 2px 0 var(--bevel-material-edge),
        0 3px 5px var(--bevel-shade);
      --bevel-depth-pressed:
        inset 0 1px 2px var(--bevel-shade),
        0 1px 0 var(--bevel-highlight);
      /* A whole object resting on the page. */
      --bevel-depth-object:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade),
        0 0 0 1px var(--bevel-material-edge),
        0 1px 1px var(--bevel-cast),
        0 6px 12px -2px var(--bevel-cast),
        0 18px 32px -8px var(--bevel-cast);
      /* The shadow under a loose object, for filter: drop-shadow(). */
      --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast);
      /* A thin object (a card) lying on another surface. */
      --bevel-depth-sheet:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px oklch(0 0 0 / 0.12),
        0 -1px 2px oklch(0 0 0 / 0.18);
    
      /* 4. Motion */
      --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */
      --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1);
      --bevel-duration-press: 100ms; /* a control giving under a finger */
      --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */
      --bevel-duration-move: 300ms; /* objects moving: cards, drawers */
      --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */
      --bevel-stagger: 30ms;
      --bevel-lift: 3px; /* how far an object rises on hover */
      --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */
      /* Unchosen items in a group sit back in shadow. */
      --bevel-dim: 0.82;
    
      /* 5. Shape */
      --bevel-radius-xs: 0.25rem;
      --bevel-radius-sm: 0.5rem;
      --bevel-radius-md: 0.625rem;
      --bevel-radius-lg: 1rem;
      --bevel-radius-xl: 1.5rem;
      --bevel-stitch-width: 1.5px;
      --bevel-stitch-inset: 0.375rem;
      /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */
      --bevel-amount: 2.25rem;
      --bevel-amount-minor: 1.25rem;
      --bevel-label: 0.75rem;
      --bevel-fine: 0.6875rem;
      --bevel-tracking-label: 0.08em; /* stamped labels */
      --bevel-tracking-print: 0.06em; /* printed marks on cards */
    
      /* Paper and ink */
      --bevel-paper: oklch(0.975 0.008 85);
      --bevel-paper-foreground: oklch(0.28 0.02 260);
      --bevel-paper-muted: oklch(0.48 0.015 260);
      --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45);
      --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45);
      --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
      /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */
      --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E");
      --bevel-ink-red: oklch(0.52 0.19 27);
      --bevel-ink-blue: oklch(0.44 0.14 262);
      --bevel-ink-green: oklch(0.5 0.12 155);
      --bevel-ink-black: oklch(0.3 0.01 260);
      --bevel-ink-amber: oklch(0.55 0.13 65);
    
      /* Brass and ivory: instrument hardware and dial faces */
      --bevel-brass: oklch(0.76 0.11 82);
      --bevel-brass-edge: oklch(0.52 0.08 70);
      --bevel-ivory: oklch(0.95 0.025 90);
      --bevel-ivory-foreground: oklch(0.25 0.015 60);
      --bevel-needle: oklch(0.55 0.2 27);
    
      /* Desk stationery: legal pads, pens, pencils, exhibit stickers */
      --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */
      --bevel-pad-foreground: oklch(0.27 0.03 255);
      --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45);
      --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55);
      --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */
      --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */
      --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */
      --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */
      --bevel-sticker-foreground: oklch(0.26 0.03 70);
      /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */
      --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
    }
    
    .dark {
      --bevel-highlight: oklch(1 0 0 / 0.14);
      /* Paper under dim light, not inverted: it's still paper. */
      --bevel-paper: oklch(0.88 0.008 85);
      --bevel-ivory: oklch(0.86 0.022 90);
      --bevel-pad: oklch(0.86 0.07 98);
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /* Depth */
    @utility bevel-recessed {
      background-color: var(--bevel-material-recess);
      box-shadow: var(--bevel-depth-recessed);
    }
    @utility bevel-flush {
      box-shadow: var(--bevel-depth-flush);
    }
    @utility bevel-raised {
      background-color: color-mix(in oklch, var(--bevel-material), white 7%);
      background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%);
      box-shadow: var(--bevel-depth-raised);
    }
    @utility bevel-pressed {
      background-color: color-mix(in oklch, var(--bevel-material), black 4%);
      background-image: none;
      box-shadow: var(--bevel-depth-pressed);
    }
    @utility bevel-object {
      box-shadow: var(--bevel-depth-object);
    }
    
    /* Materials */
    @utility bevel-leather {
      background-color: var(--bevel-material);
      background-image:
        var(--bevel-grain),
        linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade));
      background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%;
      background-blend-mode: soft-light, normal;
    }
    @utility bevel-plastic {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%),
        linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45));
      box-shadow: var(--bevel-depth-sheet);
    }
    @utility bevel-metal {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px);
      box-shadow:
        inset 0 0 0 1px oklch(0 0 0 / 0.25),
        inset 0 1px 0 var(--bevel-highlight-strong);
    }
    
    /* Details */
    @utility bevel-deboss {
      text-shadow: var(--bevel-deboss);
    }
    @utility bevel-stitch {
      border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8));
      border-style: dashed;
      border-width: var(--bevel-stitch-width);
      filter: drop-shadow(0 1px 0 var(--bevel-shade));
    }
    /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */
    @utility bevel-fill {
      background-color: var(--bevel-fill);
      background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18));
      box-shadow: 0 0 6px -1px var(--bevel-fill);
    }
    /* A small indicator light. Colour from --bevel-led. */
    @utility bevel-led {
      background-color: var(--bevel-led);
      background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%);
      box-shadow:
        0 0 6px var(--bevel-led),
        inset 0 -1px 1px oklch(0 0 0 / 0.35),
        0 1px 0 var(--bevel-highlight);
    }
    
    /* Paper: a sheet resting on the surface, with fibre in it. */
    @utility bevel-paper {
      background-color: var(--bevel-paper);
      background-image: var(--bevel-fibre);
      background-size: 240px 240px;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
    /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */
    @utility bevel-ink {
      color: var(--bevel-ink, var(--bevel-ink-red));
      border-color: currentColor;
      mix-blend-mode: multiply;
      mask-image: var(--bevel-ink-texture);
      mask-size: 160px 160px;
    }
    /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */
    @utility bevel-glass {
      pointer-events: none;
      background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%);
    }
    
    /* Material families: one per screen. Set on the screen's container. */
    [data-bevel-family="leather"] {
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
    }
    [data-bevel-family="paper"] {
      --bevel-cast: oklch(0.3 0.01 250 / 0.16);
    }
    .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) {
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /*
     * Ruled paper: rules every --bevel-rule-height and a margin line at
     * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling
     * scrolls with the content.
     */
    @utility bevel-ruled {
      background-color: var(--bevel-sheet, var(--bevel-paper));
      background-image:
        linear-gradient(
          to right,
          transparent calc(var(--bevel-rule-margin) - 1px),
          var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px),
          transparent calc(var(--bevel-rule-margin) + 1px)
        ),
        repeating-linear-gradient(
          to bottom,
          transparent 0 calc(var(--bevel-rule-height) - 1px),
          var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height)
        ),
        var(--bevel-fibre);
      background-size: 100% 100%, 100% 100%, 240px 240px;
      background-attachment: local;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
  3. Install the components Keycard is built from: Payment card.

  4. Copy keycard.tsx into components/ui.

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

    styles/keycard.css
    /*
     * Keycard: an access key on a plastic card (a PaymentCard with a magnetic
     * stripe) and the brass reader slot it goes into. Builds on bevel.css and
     * payment-card.css.
     */
    
    :root {
      --keycard-stripe: oklch(0.18 0.01 260);
      --keycard-plate-height: 3.75rem;
      --keycard-insert: 62%; /* how far the card sinks into the reader */
      --keycard-swipe-overlap: 1.25rem; /* how much of the card rides inside the swipe channel */
      --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);
    }
    
    /* Clip the card at the bottom of the reader plate; let its shadow show on the other sides. */
    @utility keycard-mouth {
      clip-path: inset(-2rem -2rem calc(var(--keycard-plate-height) * -1) -2rem);
    }
  6. Import the stylesheets after Tailwind in your global CSS, foundation first.

    globals.css
    @import "./bevel.css";
    @import "./keycard.css";

Anatomy

Each part is its own component. Leave out the ones you don't need.

Anatomy
<KeycardSlot inserted>
  <Keycard>
    <KeycardLabel />
    <KeycardKey value="sk-…" />
  </Keycard>
</KeycardSlot>

<KeycardSwipe onSwipe={useKey}>
  <Keycard>…</Keycard>
</KeycardSwipe>

Parts

PartRendersWhat it does
KeycardPaymentCardThe card, with a magnetic stripe. Takes PaymentCard's tone.
KeycardLabelspanWhose key it is.
KeycardKeyspanThe key, masked to its prefix and last four (read as “ending in”). revealed shows it all.
KeycardSlotdivA brass reader. inserted slides the card in; status text on the plate is announced politely.
KeycardSwipediv, card as buttonA swipe reader: drag the card left to right, or flick it. onSwipe fires when it completes; accepted lights the lamp. Enter or Space swipes from the keyboard.