{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loader-die-tumble",
  "title": "Loader Die Tumble",
  "description": "An ambient loading glyph shaped as a die tumbling face over face on a fixed axle: each 90deg landing overshoots by 8deg and corrects on a spring, with a floor shadow compressing and flaring in sync, rather than rotating cleanly to a stop.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/loader-die-tumble/component.tsx",
      "content": "\"use client\";\n\n// ---------------------------------------------------------------------------\n// HingeTopple — an ambient loading glyph shaped as a die tumbling face over\n// face on a fixed axle. Four pip-marked faces ring a CSS 3D cube; one\n// rotateY keyframe steps it through them 90deg at a time, but each quarter\n// isn't a clean rotation to its target — it eases in fast (ease-out-expo),\n// swings 8deg past the landing face, then corrects back on the house spring\n// curve (cubic-bezier(.34,1.56,.64,1), the same overshoot used for\n// wizard-dovetail's chip seat), so every landing reads as weight catching\n// itself rather than an animation hitting a number. A soft floor shadow\n// underneath compresses as the cube rocks up off it and flares as it lands,\n// entirely in CSS, synced to the same four-beat timeline.\n// ---------------------------------------------------------------------------\n\nconst PIP_LAYOUT: Record<number, ReadonlyArray<readonly [number, number]>> = {\n  1: [[50, 50]],\n  2: [\n    [30, 30],\n    [70, 70],\n  ],\n  3: [\n    [30, 30],\n    [50, 50],\n    [70, 70],\n  ],\n  4: [\n    [30, 30],\n    [70, 30],\n    [30, 70],\n    [70, 70],\n  ],\n};\n\nconst FACES: ReadonlyArray<{ pips: number; transform: string; shade: number }> = [\n  { pips: 1, transform: \"rotateY(0deg)\", shade: 0 },\n  { pips: 2, transform: \"rotateY(90deg)\", shade: 10 },\n  { pips: 3, transform: \"rotateY(180deg)\", shade: 16 },\n  { pips: 4, transform: \"rotateY(-90deg)\", shade: 10 },\n];\n\nconst CSS = `\n.ns-ht-stage{\n  position:relative;\n  perspective:calc(var(--ns-ht-size) * 5);\n  width:var(--ns-ht-size);\n  height:var(--ns-ht-size);\n}\n.ns-ht-cube{\n  position:absolute;\n  inset:0;\n  transform-style:preserve-3d;\n  animation:ns-ht-spin var(--ns-ht-period) infinite;\n}\n.ns-ht-face{\n  position:absolute;\n  inset:0;\n  border-radius:6px;\n  border:1px solid var(--border);\n  background:var(--surface);\n  backface-visibility:hidden;\n  display:grid;\n  grid-template-columns:repeat(3,1fr);\n  grid-template-rows:repeat(3,1fr);\n  padding:18%;\n}\n.ns-ht-face::before{\n  content:\"\";\n  position:absolute;\n  inset:0;\n  border-radius:6px;\n  background:var(--ns-ht-shade);\n  pointer-events:none;\n}\n.ns-ht-pip{\n  width:14%;\n  height:14%;\n  border-radius:9999px;\n  background:var(--foreground);\n  align-self:center;\n  justify-self:center;\n}\n.ns-ht-floor{\n  position:absolute;\n  left:50%;\n  bottom:-10%;\n  width:70%;\n  height:10%;\n  transform:translateX(-50%);\n  border-radius:9999px;\n  background:var(--foreground);\n  filter:blur(3px);\n  animation:ns-ht-floor var(--ns-ht-period) infinite;\n}\n@keyframes ns-ht-spin{\n  0%{transform:rotateY(0deg);animation-timing-function:linear}\n  14%{transform:rotateY(0deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}\n  20%{transform:rotateY(-98deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}\n  25%{transform:rotateY(-90deg);animation-timing-function:linear}\n  39%{transform:rotateY(-90deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}\n  45%{transform:rotateY(-188deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}\n  50%{transform:rotateY(-180deg);animation-timing-function:linear}\n  64%{transform:rotateY(-180deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}\n  70%{transform:rotateY(-278deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}\n  75%{transform:rotateY(-270deg);animation-timing-function:linear}\n  89%{transform:rotateY(-270deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}\n  95%{transform:rotateY(-368deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}\n  100%{transform:rotateY(-360deg)}\n}\n@keyframes ns-ht-floor{\n  0%,14%{transform:translateX(-50%) scaleX(1);opacity:.45}\n  20%,45%,70%,95%{transform:translateX(-50%) scaleX(.55);opacity:.28}\n  25%,50%,75%,100%{transform:translateX(-50%) scaleX(1.2);opacity:.6}\n  39%,64%,89%{transform:translateX(-50%) scaleX(1);opacity:.45}\n}\n@media (prefers-reduced-motion: reduce){\n  .ns-ht-cube,.ns-ht-floor{animation:none;}\n  .ns-ht-floor{transform:translateX(-50%) scaleX(1);opacity:.45;}\n}\n`;\n\nexport interface HingeToppleProps {\n  /** cube edge length in px */\n  size?: number;\n  /** ms for one full four-face loop */\n  periodMs?: number;\n  /** text announced via the component's own aria-live region */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function HingeTopple({\n  size = 48,\n  periodMs = 4800,\n  label = \"Loading\",\n  className = \"\",\n}: HingeToppleProps) {\n  const half = size / 2;\n  return (\n    <span\n      role=\"status\"\n      aria-live=\"polite\"\n      data-loader-die-tumble\n      className={`inline-flex items-center justify-center ${className}`}\n      style={{\n        width: size,\n        paddingBottom: size * 0.18,\n        [\"--ns-ht-size\" as string]: `${size}px`,\n        [\"--ns-ht-period\" as string]: `${periodMs}ms`,\n      }}\n    >\n      <style>{CSS}</style>\n      <span className=\"ns-ht-stage\" aria-hidden=\"true\">\n        <span className=\"ns-ht-cube\">\n          {FACES.map((face) => (\n            <span\n              key={face.pips}\n              className=\"ns-ht-face\"\n              style={{\n                transform: `${face.transform} translateZ(${half}px)`,\n                [\"--ns-ht-shade\" as string]: `color-mix(in srgb, var(--foreground) ${face.shade}%, transparent)`,\n              }}\n            >\n              {PIP_LAYOUT[face.pips]!.map(([x, y], i) => (\n                <span\n                  key={i}\n                  className=\"ns-ht-pip\"\n                  style={{ gridColumn: x < 50 ? 1 : x > 50 ? 3 : 2, gridRow: y < 50 ? 1 : y > 50 ? 3 : 2 }}\n                />\n              ))}\n            </span>\n          ))}\n        </span>\n        <span className=\"ns-ht-floor\" />\n      </span>\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/loader-die-tumble.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-surface": "var(--surface)"
    },
    "light": {
      "surface": "#fafafa"
    },
    "dark": {
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "loading",
      "spinner",
      "indicator",
      "3d",
      "svg",
      "css-animation",
      "physics",
      "aria-live",
      "icon"
    ],
    "instruction": "A compact, always-on loading glyph (20-96px, sized via a `size` prop) built as a 3D CSS cube ring — four faces (`transform-style:preserve-3d`, each `rotateY(0/90/180/-90deg) translateZ(size/2)`) marked with die pips (1/2/3/4, a 3x3 grid per face) so every quarter-turn is legible as a distinct landing rather than an ambiguous spin. One `rotateY` keyframe drives the whole cycle in four identical beats: each beat holds still, then eases fast toward its target face on ease-out-expo, overshoots the landing angle by 8deg, and corrects back on the house spring curve (cubic-bezier(.34,1.56,.64,1), the same overshoot wizard-dovetail uses for a chip seating into its rail) — set via a distinct `animation-timing-function` on the keyframe stop straddling each leg, not a separate easing library. A soft blurred ellipse beneath the cube is a second, synced keyframe: it compresses and dims as the cube rocks up off it mid-topple, then flares wider and brighter for a beat on landing before settling — a floor absorbing an impact, not a static shadow prop. Every face background is `var(--surface)` with a `var(--border)` outline and pips in `var(--foreground)`; face-to-face shading (front lightest, opposite face darkest) is a `color-mix(in srgb, var(--foreground) N%, transparent)` overlay per face, never a hex literal, so it holds correctly in both themes. The whole glyph is a single `role=\"status\" aria-live=\"polite\"` element; the cube and floor are `aria-hidden` and a visually hidden text node (the `label` prop, default 'Loading') is the only thing announced — nothing here is interactive, so it renders zero controls and is correctly exempt from the tabbability check. `prefers-reduced-motion` removes both animations entirely, leaving the cube resting on face one and the floor at its neutral resting scale — legible as 'something is here' with zero motion. Props: size (px, default 48), periodMs (full four-face loop, default 4800), label, className. Pure DOM + CSS 3D transforms, zero dependencies, no canvas, no framer-motion despite the source this was rebuilt from declaring it as a dependency it never actually imported."
  },
  "type": "registry:ui"
}