Rating Stamp

Rating

Rating / level input rendered as a row of seal impressions: chosen levels stamp solid with a physical thud and an expanding impression ring, the rest wait as faint unfilled outlines, so the value reads unambiguously even in a still frame.

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

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";

// ---------------------------------------------------------------------------
// ChopPress — a rating / level input rendered as a row of seal impressions.
// Chosen levels are stamped solid with a short physical "thud" (scale
// 1.15 -> 1.0, ease-out-expo) while a single concentric ring in --border
// expands and fades outward, reading as paper taking the impression.
// Unfilled levels wait as faint --muted outlines. Lowering the value drains
// marks right-to-left with a 30ms stagger, as if the ink is lifting off in
// sequence. Hovering (or focusing) a level dashes the outline of whichever
// marks would change if you committed there, without committing anything.
//
// Real ARIA radiogroup semantics underneath: one role="radio" per level,
// only the committed level's radio is aria-checked, roving tabindex, arrow
// keys move AND commit (Home/End jump to the ends), and each radio's
// accessible name reads "N of M" plus an optional word ("3 of 5 - High").
// The stamp is a purely visual layer on top — nothing here depends on a
// screen reader ever seeing the seal glyph. Reduced motion: stamps land and
// drain instantly, no scale/ring/stagger.
//
// Pure DOM/SVG/CSS — no canvas. All ink is token-relative: --foreground for
// the stamp body, --muted for resting outlines, --border for the impression
// ring, --accent only for the keyboard focus ring.
// ---------------------------------------------------------------------------

const PRESS_MS = 140; // press-landing scale, ease-out-expo
const RING_MS = 380; // impression ring expand + fade
const PRESS_FILL_MS = 90; // ink appears fast — a thud, not a fade-in
const DRAIN_FILL_MS = 170; // ink lifts gradually
const STAGGER_MS = 30; // per-mark delay, both directions

const SIZE_PX = { sm: 26, md: 34, lg: 42 } as const;

type Kind = "press" | "drain";

export interface ChopPressProps {
  /** Accessible name for the radiogroup, e.g. "Priority". */
  label: string;
  /** Number of levels. @default 5 */
  max?: number;
  /** Controlled value, 0..max. 0 = nothing stamped yet. */
  value?: number;
  /** Initial value when uncontrolled. @default 0 */
  defaultValue?: number;
  /** Fires on commit — click, or an arrow/Home/End key move. Never on hover. */
  onValueChange?: (value: number) => void;
  /**
   * Optional word per level (index 0 = level 1), appended to that level's
   * accessible name — e.g. `["Low", "", "", "", "Critical"]` reads level 5
   * as "5 of 5 - Critical". Sparse arrays are fine, empty entries are
   * skipped.
   */
  levelNames?: string[];
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  className?: string;
}

type Vars = React.CSSProperties & Record<`--${string}`, string | number>;

function clampLevel(v: number, max: number) {
  if (!Number.isFinite(v)) return 0;
  return Math.min(max, Math.max(0, Math.round(v)));
}

// Deterministic per-index variation (rotation + corner radius) so seals read
// as individually struck rather than one glyph rubber-stamped M times.
// Seeded off the index only — identical across renders and between the two
// themes.
function sealVariant(i: number) {
  const s = Math.sin(i * 12.9898 + 4.1414) * 43758.5453;
  const frac = s - Math.floor(s);
  return {
    rotate: (frac - 0.5) * 5, // -2.5..2.5deg
    rx: 3.2 + frac * 1.8, // 3.2..5 corner radius, viewBox units
  };
}

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

export function ChopPress({
  label,
  max = 5,
  value,
  defaultValue = 0,
  onValueChange,
  levelNames,
  size = "md",
  className = "",
}: ChopPressProps) {
  const M = Math.max(1, Math.floor(max));
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(() => clampLevel(defaultValue, M));
  const committed = clampLevel(isControlled ? (value as number) : internal, M);

  const reducedMotion = useReducedMotion();
  const prevRef = useRef(committed);
  const [batch, setBatch] = useState(0);
  const [transitions, setTransitions] = useState<
    Map<number, { kind: Kind; delay: number }>
  >(new Map());
  const [hoverIndex, setHoverIndex] = useState<number | null>(null);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const groupId = useId();

  // Runs before paint whenever the committed value changes, whichever path
  // caused it (click, arrow key, or a controlled prop update from outside).
  // Building the transition map here — not inside the click handler — means
  // both paths animate identically and there's no one-frame flash of the
  // final state before the animation classes attach.
  useLayoutEffect(() => {
    const prev = prevRef.current;
    if (committed !== prev) {
      if (reducedMotion) {
        setTransitions(new Map());
      } else {
        const map = new Map<number, { kind: Kind; delay: number }>();
        if (committed > prev) {
          for (let i = prev; i < committed; i++) {
            map.set(i, { kind: "press", delay: (i - prev) * STAGGER_MS });
          }
        } else {
          for (let i = committed; i < prev; i++) {
            // right-to-left: the highest index (rightmost) drains first.
            map.set(i, { kind: "drain", delay: (prev - 1 - i) * STAGGER_MS });
          }
        }
        setTransitions(map);
        setBatch((b) => b + 1);
      }
      prevRef.current = committed;
    }
  }, [committed, reducedMotion]);

  const commit = useCallback(
    (next: number) => {
      const clamped = clampLevel(next, M);
      if (!isControlled) setInternal(clamped);
      if (clamped !== committed) onValueChange?.(clamped);
    },
    [M, isControlled, committed, onValueChange]
  );

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    let next: number | null = null;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      next = Math.min(M, Math.max(1, committed + 1));
    } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      next = Math.max(1, committed - 1);
    } else if (e.key === "Home") {
      next = 1;
    } else if (e.key === "End") {
      next = M;
    } else {
      return;
    }
    e.preventDefault();
    commit(next);
    btnRefs.current[next - 1]?.focus();
  };

  const levels = useMemo(() => Array.from({ length: M }, (_, i) => i), [M]);

  return (
    <div
      role="radiogroup"
      aria-label={label}
      onKeyDown={onKeyDown}
      data-reduced={reducedMotion || undefined}
      className={`ns-cp-group ${className}`}
      style={{ ["--cp-size"]: `${SIZE_PX[size]}px` } as Vars}
    >
      {levels.map((i) => {
        const filled = i < committed;
        const previewTarget = hoverIndex !== null ? i <= hoverIndex : filled;
        const previewing = hoverIndex !== null && previewTarget !== filled;
        const checked = i === committed - 1;
        const t = transitions.get(i);
        const variant = sealVariant(i);
        const level = i + 1;
        const name = levelNames?.[i];
        const ariaLabel = `${level} of ${M}${name ? ` - ${name}` : ""}`;
        const tabbable = committed === 0 ? i === 0 : i === committed - 1;
        const fillMs = t?.kind === "drain" ? DRAIN_FILL_MS : PRESS_FILL_MS;

        return (
          <button
            key={i}
            ref={(el) => {
              btnRefs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={checked}
            aria-label={ariaLabel}
            id={`${groupId}-${i}`}
            tabIndex={tabbable ? 0 : -1}
            data-transition={t?.kind}
            className="ns-cp-seal"
            style={
              {
                "--cp-delay": `${t?.delay ?? 0}ms`,
                "--cp-fill-ms": `${fillMs}ms`,
              } as Vars
            }
            onClick={() => commit(level)}
            onMouseEnter={() => setHoverIndex(i)}
            onMouseLeave={() => setHoverIndex((h) => (h === i ? null : h))}
            onFocus={() => setHoverIndex(i)}
            onBlur={() => setHoverIndex((h) => (h === i ? null : h))}
          >
            <svg
              viewBox="0 0 32 32"
              aria-hidden="true"
              focusable="false"
              className="ns-cp-glyph"
              style={{ transform: `rotate(${variant.rotate.toFixed(2)}deg)` }}
            >
              <rect
                x={3}
                y={3}
                width={26}
                height={26}
                rx={variant.rx}
                className="ns-cp-base"
                data-filled={filled || undefined}
                data-preview={previewing || undefined}
              />
              <path
                d={`M 6 ${(6 + variant.rx).toFixed(1)} L 6 6 L ${(6 + variant.rx).toFixed(1)} 6`}
                className="ns-cp-edge-hi"
                data-filled={filled || undefined}
              />
              <path
                d={`M ${(26 - variant.rx).toFixed(1)} 26 L 26 26 L 26 ${(26 - variant.rx).toFixed(1)}`}
                className="ns-cp-edge-lo"
                data-filled={filled || undefined}
              />
            </svg>
            {t?.kind === "press" && (
              <span key={`ring-${batch}-${i}`} className="ns-cp-ring" aria-hidden="true" />
            )}
          </button>
        );
      })}

      <style>{`
        .ns-cp-group {
          display: inline-flex;
          align-items: center;
          gap: 8px;
        }
        .ns-cp-seal {
          position: relative;
          width: var(--cp-size, 34px);
          height: var(--cp-size, 34px);
          padding: 0;
          border: none;
          border-radius: 6px;
          background: transparent;
          color: inherit;
          cursor: pointer;
          display: grid;
          place-items: center;
          outline: none;
          -webkit-tap-highlight-color: transparent;
        }
        .ns-cp-seal:hover {
          background: color-mix(in oklab, var(--foreground) 4%, transparent);
        }
        .ns-cp-seal:focus-visible {
          outline: 2px solid var(--accent);
          outline-offset: 2px;
        }
        .ns-cp-glyph {
          width: 68%;
          height: 68%;
          overflow: visible;
        }
        .ns-cp-base {
          /* fill is always --foreground; only fill-opacity animates. a
             color <-> "none" transition is a discrete 50% flip in every
             engine, not an interpolation — fill-opacity is a plain number
             and always interpolates smoothly. */
          fill: var(--foreground);
          fill-opacity: 0;
          stroke: var(--muted);
          stroke-width: 1.5px;
          transition:
            fill-opacity var(--cp-fill-ms, 140ms) ease var(--cp-delay, 0ms),
            stroke 140ms ease var(--cp-delay, 0ms),
            stroke-dasharray 120ms ease;
        }
        .ns-cp-base[data-filled] {
          fill-opacity: 1;
          stroke-opacity: 0.4;
        }
        .ns-cp-base[data-preview] {
          stroke-dasharray: 3 2.5;
          stroke: color-mix(in oklab, var(--foreground) 55%, var(--muted));
        }
        .ns-cp-edge-hi,
        .ns-cp-edge-lo {
          fill: none;
          stroke-width: 1px;
          opacity: 0;
          transition: opacity var(--cp-fill-ms, 140ms) ease var(--cp-delay, 0ms);
        }
        .ns-cp-edge-hi[data-filled],
        .ns-cp-edge-lo[data-filled] {
          opacity: 1;
        }
        .ns-cp-edge-hi {
          stroke: color-mix(in oklab, var(--background) 45%, transparent);
        }
        .ns-cp-edge-lo {
          stroke: var(--border);
        }

        .ns-cp-seal[data-transition="press"] {
          animation: ns-cp-press ${PRESS_MS}ms cubic-bezier(0.16, 1, 0.3, 1) both;
          animation-delay: var(--cp-delay, 0ms);
        }

        .ns-cp-ring {
          position: absolute;
          inset: 2px;
          border-radius: 6px;
          border: 1px solid var(--border);
          pointer-events: none;
          opacity: 0.9;
          transform: scale(0.85);
          animation: ns-cp-ring ${RING_MS}ms ease-out both;
          animation-delay: var(--cp-delay, 0ms);
        }

        @keyframes ns-cp-press {
          from { transform: scale(1.15); }
          to { transform: scale(1); }
        }
        @keyframes ns-cp-ring {
          from { opacity: 0.9; transform: scale(0.9); }
          to { opacity: 0; transform: scale(1.6); }
        }

        .ns-cp-group[data-reduced] .ns-cp-base,
        .ns-cp-group[data-reduced] .ns-cp-edge-hi,
        .ns-cp-group[data-reduced] .ns-cp-edge-lo {
          transition: none;
        }
        .ns-cp-group[data-reduced] .ns-cp-seal[data-transition],
        .ns-cp-group[data-reduced] .ns-cp-ring {
          animation: none;
          display: none;
        }

        @media (prefers-reduced-motion: reduce) {
          .ns-cp-base,
          .ns-cp-edge-hi,
          .ns-cp-edge-lo {
            transition: none;
          }
          .ns-cp-seal[data-transition],
          .ns-cp-ring {
            animation: none;
          }
        }
      `}</style>
    </div>
  );
}
Use when

Rating / level input rendered as a row of seal impressions: chosen levels stamp solid with a physical thud and an expanding impression ring, the rest wait as faint unfilled outlines, so the value reads unambiguously even in a still frame. A radio-group underneath, not a decoration.

Build spec

A rating / level picker (priority, difficulty, satisfaction) rendered as a row of physical seal impressions instead of stars. Each level is one SVG square seal glyph, subtly varied per index by a deterministic seed (small +/-2.5deg rotation, 3.2-5 corner radius) so the row reads as individually struck rather than one glyph rubber-stamped M times. STRUCTURE: real ARIA radiogroup — one role=radio button per level, only the committed level's radio carries aria-checked=true (the rest of the visually-filled marks below it are aria-checked=false, mirroring how sighted-only 'fill up to N' works in a star rating: screen readers hear a single-select list, not a range), roving tabindex so Tab reaches exactly one stop, and each radio's accessible name is 'N of M' plus an optional word from levelNames (e.g. '3 of 5 - High'). MECHANISM: committing upward — click any level, or ArrowRight/Up/Home/End on the group (arrow keys move AND commit, matching native radiogroup behavior; DOM focus is moved imperatively to the newly-committed radio so the visual focus ring always tracks the ARIA-checked one) — scales each newly-filled mark 1.15 -> 1.0 over 140ms ease-out-expo (cubic-bezier(0.16,1,0.3,1)), the press landing, while a single concentric ring in --border expands from scale(0.9) to scale(1.6) and fades opacity 0.9 -> 0 over 380ms, reading as paper taking the impression; ink itself (fill: none -> var(--foreground)) appears fast, over 90ms, a thud rather than a fade-in. Lowering the value drains the marks that fall out of range right-to-left (highest index first) with a 30ms stagger per mark, fill fading out over 170ms — ink lifting off in sequence, the mirror image of stamping down. Both directions are driven by one CSS custom-property-timed transition/animation pair (--cp-delay, --cp-fill-ms) computed in a layout effect keyed off the committed value, so a controlled value change from outside animates identically to a click. Unfilled marks are a 1px --muted outline square; filled marks add two small L-shaped corner strokes (top-left in a --background-mixed highlight, bottom-right in --border) suggesting an embossed bevel. HOVER / FOCUS PREVIEW: hovering (or keyboard-focusing) a level dashes the outline (stroke-dasharray, stroke shifted toward --foreground) of every mark whose filled state would change if you committed there — whether that means marks about to gain ink or marks about to lose it — without touching the committed value; the same state variable drives both pointer and focus so the preview genuinely mirrors between mouse and keyboard. REDUCED MOTION: the press/ring/drain animations and their transition-delays are stripped entirely (both via a JS matchMedia check that skips building the transition map, and a belt-and-suspenders prefers-reduced-motion CSS block) — marks snap straight to their filled/unfilled state, fully legible and functional, just not animated. Pure DOM+SVG+CSS, no canvas; all ink is token-relative (--foreground, --muted, --border, --background, --accent only for the keyboard focus ring) so both themes restyle for free. Distinct from pricing-scale (a two-pan pricing/plan balance-scale with canvas beam physics) by being a compact inline rating field for ordinary forms whose entire mechanism is the stamp impression, not a weighing metaphor — the registry otherwise has no rating input at all.

Tags
ratingradio-groupformlevel-pickerstampmicro-interaction