# Handset — Animated Card for React

> Live 3D device mockup: a studio-lit beveled phone whose screen renders your real React children as DOM. Springs between present, landscape, exploded-stack and back poses (or scroll-scrubs via progress), drag to spin, rises in with a soft contact shadow. Offscreen-paused, reduced-motion-safe, CSS-frame fallback.

- Page: https://crucibleui.com/components/cards/handset
- Category: [cards](https://crucibleui.com/components/cards)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: r3f
- npm dependencies: three, @react-three/fiber, @react-three/drei

## Install

```bash
npx shadcn@latest add @crucible/handset
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/handset`.

## Props (`HandsetProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `React.ReactNode` | — | Live React content rendered ON the screen as real DOM (focusable, clickable), laid out in a `screenWidth`-px-wide portrait viewport. Note: it mounts in a separate React root, so app-level context providers do not reach it — pass data in as props. |
| `screenSrc?` | `string` | — | Image URL shown on the screen when no `children` are given (cover-fit). |
| `pose?` | `HandsetPose` | `"present". Overridden by `progress` when set` | Preset pose. |
| `progress?` | `number` | — | Scroll-scrub control, 0–1. Overrides `pose`: 0 → present, .33 → landscape, .66 → explode, 1 → back, interpolated in between. |
| `finish?` | `HandsetFinish` | `"metal" (brushed titanium-silver)` | Body material. |
| `interactive?` | `boolean` | `true` | Drag (or focus + Left/Right arrows) to spin; springs back to the current pose. |
| `paused?` | `boolean` | — | Freeze all motion: no intro, no idle drift, pose changes are instant and drag is disabled. Reduced motion behaves the same (drag stays, damped). |
| `screenWidth?` | `number` | `300` | CSS width (px) of the screen's layout viewport. |
| `label?` | `string` | `"Device mockup"` | Accessible name for the device region. |
| `className?` | `string` | ``relative aspect-[4/5] w-full`` | Extra classes for the wrapper. |
| `ref?` | `React.Ref<HandsetHandle>` | — | Imperative handle: `setPose()`, `replay()`. |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Handset, type HandsetPose } from "@/components/crucible/handset";

const POSES: { value: HandsetPose; label: string }[] = [
  { value: "present", label: "Present" },
  { value: "landscape", label: "Landscape" },
  { value: "explode", label: "Explode" },
  { value: "back", label: "Back" },
];

const ROWS = [
  { title: "Review the draft", meta: "9:30", tint: "bg-sky-400/80" },
  { title: "Sync with design", meta: "11:00", tint: "bg-violet-400/80" },
  { title: "Ship the release", meta: "16:45", tint: "bg-emerald-400/80" },
];

function MockApp() {
  const [done, setDone] = React.useState<boolean[]>([true, false, false]);
  const left = done.filter((d) => !d).length;
  return (
    <div className="flex h-full w-full flex-col bg-neutral-950 px-5 pb-6 pt-12 text-neutral-100">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-xs font-medium uppercase tracking-[0.18em] text-neutral-500">Today</p>
          <p className="mt-1 text-2xl font-semibold tracking-tight">Tasks</p>
        </div>
        <div className="h-9 w-9 rounded-full bg-gradient-to-br from-neutral-300 to-neutral-600" />
      </div>
      <p className="mt-3 text-sm text-neutral-400">{left === 0 ? "All done." : `${left} left to go`}</p>
      <ul className="mt-6 flex flex-col gap-2.5">
        {ROWS.map((row, i) => (
          <li key={row.title}>
            <button
              type="button"
              role="checkbox"
              aria-checked={done[i]}
              onClick={() => setDone((d) => d.map((v, j) => (j === i ? !v : v)))}
              className="flex w-full items-center gap-3 rounded-2xl bg-white/[0.05] px-3.5 py-3 text-left outline-none transition-colors hover:bg-white/[0.08] focus-visible:ring-2 focus-visible:ring-white/60"
            >
              <span
                className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border ${
                  done[i] ? "border-transparent bg-white text-neutral-950" : "border-neutral-600"
                }`}
              >
                {done[i] ? (
                  <svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden>
                    <path d="M2.5 6.2 5 8.5l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" />
                  </svg>
                ) : null}
              </span>
              <span className="min-w-0 flex-1">
                <span
                  className={`block truncate text-[15px] font-medium ${done[i] ? "text-neutral-500 line-through" : ""}`}
                >
                  {row.title}
                </span>
                <span className="mt-0.5 flex items-center gap-1.5 text-xs text-neutral-500">
                  <span className={`h-1.5 w-1.5 rounded-full ${row.tint}`} />
                  {row.meta}
                </span>
              </span>
            </button>
          </li>
        ))}
      </ul>
      <button
        type="button"
        onClick={() => setDone([false, false, false])}
        className="mt-auto rounded-2xl bg-white py-3.5 text-[15px] font-semibold text-neutral-950 outline-none transition-transform active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
      >
        Reset list
      </button>
    </div>
  );
}

function PoseControl({ value, onChange }: { value: HandsetPose; onChange: (p: HandsetPose) => void }) {
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const onKeyDown = (e: React.KeyboardEvent, i: number) => {
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % POSES.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + POSES.length) % POSES.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = POSES.length - 1;
    if (next < 0) return;
    e.preventDefault();
    onChange(POSES[next].value);
    refs.current[next]?.focus();
  };
  return (
    <div
      role="radiogroup"
      aria-label="Device pose"
      className="flex rounded-full border border-white/10 bg-white/[0.04] p-1 backdrop-blur"
    >
      {POSES.map((p, i) => {
        const checked = p.value === value;
        return (
          <button
            key={p.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={checked}
            tabIndex={checked ? 0 : -1}
            onClick={() => onChange(p.value)}
            onKeyDown={(e) => onKeyDown(e, i)}
            className={`rounded-full px-3.5 py-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-white/60 ${
              checked ? "bg-white text-neutral-950" : "text-neutral-400 hover:text-neutral-100"
            }`}
          >
            {p.label}
          </button>
        );
      })}
    </div>
  );
}

export default function HandsetDemo() {
  const [pose, setPose] = React.useState<HandsetPose>("present");
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex h-full w-full flex-col items-center justify-center gap-3 py-5">
        <Handset pose={pose} className="h-[min(440px,62vh)] w-auto max-w-full">
          <MockApp />
        </Handset>
        <PoseControl value={pose} onChange={setPose} />
        <p className="text-sm text-neutral-500">Drag to spin — pick a pose.</p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.
