Loader Die Tumble

Loader

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.

Install
npx shadcn add https://design.helpmarq.com/r/loader-die-tumble.json
Source
registry/core/loader-die-tumble/component.tsx
"use client";

// ---------------------------------------------------------------------------
// HingeTopple — an ambient loading glyph shaped as a die tumbling face over
// face on a fixed axle. Four pip-marked faces ring a CSS 3D cube; one
// rotateY keyframe steps it through them 90deg at a time, but each quarter
// isn't a clean rotation to its target — it eases in fast (ease-out-expo),
// swings 8deg past the landing face, then corrects back on the house spring
// curve (cubic-bezier(.34,1.56,.64,1), the same overshoot used for
// wizard-dovetail's chip seat), so every landing reads as weight catching
// itself rather than an animation hitting a number. A soft floor shadow
// underneath compresses as the cube rocks up off it and flares as it lands,
// entirely in CSS, synced to the same four-beat timeline.
// ---------------------------------------------------------------------------

const PIP_LAYOUT: Record<number, ReadonlyArray<readonly [number, number]>> = {
  1: [[50, 50]],
  2: [
    [30, 30],
    [70, 70],
  ],
  3: [
    [30, 30],
    [50, 50],
    [70, 70],
  ],
  4: [
    [30, 30],
    [70, 30],
    [30, 70],
    [70, 70],
  ],
};

const FACES: ReadonlyArray<{ pips: number; transform: string; shade: number }> = [
  { pips: 1, transform: "rotateY(0deg)", shade: 0 },
  { pips: 2, transform: "rotateY(90deg)", shade: 10 },
  { pips: 3, transform: "rotateY(180deg)", shade: 16 },
  { pips: 4, transform: "rotateY(-90deg)", shade: 10 },
];

const CSS = `
.ns-ht-stage{
  position:relative;
  perspective:calc(var(--ns-ht-size) * 5);
  width:var(--ns-ht-size);
  height:var(--ns-ht-size);
}
.ns-ht-cube{
  position:absolute;
  inset:0;
  transform-style:preserve-3d;
  animation:ns-ht-spin var(--ns-ht-period) infinite;
}
.ns-ht-face{
  position:absolute;
  inset:0;
  border-radius:6px;
  border:1px solid var(--border);
  background:var(--surface);
  backface-visibility:hidden;
  display:grid;
  grid-template-columns:repeat(3,1fr);
  grid-template-rows:repeat(3,1fr);
  padding:18%;
}
.ns-ht-face::before{
  content:"";
  position:absolute;
  inset:0;
  border-radius:6px;
  background:var(--ns-ht-shade);
  pointer-events:none;
}
.ns-ht-pip{
  width:14%;
  height:14%;
  border-radius:9999px;
  background:var(--foreground);
  align-self:center;
  justify-self:center;
}
.ns-ht-floor{
  position:absolute;
  left:50%;
  bottom:-10%;
  width:70%;
  height:10%;
  transform:translateX(-50%);
  border-radius:9999px;
  background:var(--foreground);
  filter:blur(3px);
  animation:ns-ht-floor var(--ns-ht-period) infinite;
}
@keyframes ns-ht-spin{
  0%{transform:rotateY(0deg);animation-timing-function:linear}
  14%{transform:rotateY(0deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}
  20%{transform:rotateY(-98deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}
  25%{transform:rotateY(-90deg);animation-timing-function:linear}
  39%{transform:rotateY(-90deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}
  45%{transform:rotateY(-188deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}
  50%{transform:rotateY(-180deg);animation-timing-function:linear}
  64%{transform:rotateY(-180deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}
  70%{transform:rotateY(-278deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}
  75%{transform:rotateY(-270deg);animation-timing-function:linear}
  89%{transform:rotateY(-270deg);animation-timing-function:cubic-bezier(.16,1,.3,1)}
  95%{transform:rotateY(-368deg);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}
  100%{transform:rotateY(-360deg)}
}
@keyframes ns-ht-floor{
  0%,14%{transform:translateX(-50%) scaleX(1);opacity:.45}
  20%,45%,70%,95%{transform:translateX(-50%) scaleX(.55);opacity:.28}
  25%,50%,75%,100%{transform:translateX(-50%) scaleX(1.2);opacity:.6}
  39%,64%,89%{transform:translateX(-50%) scaleX(1);opacity:.45}
}
@media (prefers-reduced-motion: reduce){
  .ns-ht-cube,.ns-ht-floor{animation:none;}
  .ns-ht-floor{transform:translateX(-50%) scaleX(1);opacity:.45;}
}
`;

export interface HingeToppleProps {
  /** cube edge length in px */
  size?: number;
  /** ms for one full four-face loop */
  periodMs?: number;
  /** text announced via the component's own aria-live region */
  label?: string;
  className?: string;
}

export function HingeTopple({
  size = 48,
  periodMs = 4800,
  label = "Loading",
  className = "",
}: HingeToppleProps) {
  const half = size / 2;
  return (
    <span
      role="status"
      aria-live="polite"
      data-loader-die-tumble
      className={`inline-flex items-center justify-center ${className}`}
      style={{
        width: size,
        paddingBottom: size * 0.18,
        ["--ns-ht-size" as string]: `${size}px`,
        ["--ns-ht-period" as string]: `${periodMs}ms`,
      }}
    >
      <style>{CSS}</style>
      <span className="ns-ht-stage" aria-hidden="true">
        <span className="ns-ht-cube">
          {FACES.map((face) => (
            <span
              key={face.pips}
              className="ns-ht-face"
              style={{
                transform: `${face.transform} translateZ(${half}px)`,
                ["--ns-ht-shade" as string]: `color-mix(in srgb, var(--foreground) ${face.shade}%, transparent)`,
              }}
            >
              {PIP_LAYOUT[face.pips]!.map(([x, y], i) => (
                <span
                  key={i}
                  className="ns-ht-pip"
                  style={{ gridColumn: x < 50 ? 1 : x > 50 ? 3 : 2, gridRow: y < 50 ? 1 : y > 50 ? 3 : 2 }}
                />
              ))}
            </span>
          ))}
        </span>
        <span className="ns-ht-floor" />
      </span>
      <span className="sr-only">{label}</span>
    </span>
  );
}
Build spec

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.

Tags
loaderloadingspinnerindicator3dsvgcss-animationphysicsaria-liveicon