{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "score-drop",
  "title": "Score drop",
  "description": "A card with a question, a tick ruler with a needle and a big score whose digits drop in one by one with a springy bounce; in dark a light in the score’s colour rises from the bottom as the score grows. Read-only, or a slider you can drag or key.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/score-drop/score-drop.tsx",
      "content": "// ScoreDrop: a rounded card that asks a question and answers it with a score. A fine tick ruler bends over the\n// middle with a needle that points at the value, and the score sits big at the bottom. When the card comes into view,\n// or the value changes, the digits drop in from above one after another and land with a springy bounce. In dark\n// mode a warm light rises from the bottom of the card, and it grows with the value; in light mode the card stays\n// clean and white.\n//\n// How it works: the ruler is a shallow arc of hairline ticks around a pivot far below the card, so it reads as a\n// ruler, not a gauge. Ticks up to the value are drawn in ink, the rest faint, and both fade towards the ends. Pass\n// `interactive` (or `onValueChange`) and the ruler and score become one slider: arrow keys step, Page Up and Page\n// Down step ten times, Home and End jump to the ends, and dragging sideways over it sets the value. Changes made by\n// hand swap the digits with a short, calm drop, so a drag never turns into a shower of bouncing numbers.\n//\n// Screen readers: read-only it is a meter, interactive a slider. Both are named by the title (or `label`) and carry\n// the value as text (\"58 out of 100\"). The digits, the ruler and the end labels are hidden, because the value text\n// already says them.\n//\n// Motion: digits use Motion springs; the needle turns and the light grows with CSS transitions on transform and\n// opacity only. Under reduced motion the final state is drawn at once: no drop, no turn, no grow.\nimport { AnimatePresence, motion, useInView } from 'motion/react'\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\n\nexport type ScoreDropProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title' | 'onChange'> & {\n  /** The score. With `onValueChange` it is controlled; with `interactive` alone it is only the starting value. */\n  value: number\n  /** Called with the new value when a key or a drag changes it. Passing it makes the card interactive. */\n  onValueChange?: (value: number) => void\n  /** Turn the ruler into a slider you can drag or key. Default: true when `onValueChange` is set. */\n  interactive?: boolean\n  /** The question or title at the top. Wrap words in `<strong>` to set them in full ink: `How <strong>calm</strong> was it?` */\n  title: ReactNode\n  /** The accessible name, when the title is not plain enough to read out. Default: the title's text. */\n  label?: string\n  /** A small line under the ruler, such as \"out of 100\". */\n  caption?: ReactNode\n  /** The value at the left end of the ruler. Default 0. */\n  min?: number\n  /** The value at the right end of the ruler. Default 100. */\n  max?: number\n  /** How far one arrow key or one tick of a drag moves the value. Default 1. */\n  step?: number\n  /** Any CSS colour for the light that rises in dark mode and the needle's tip. Default `var(--primary)`. */\n  glow?: string\n  /** The labels under the two ends of the ruler. Default `min` and `max`; false hides them. */\n  ends?: [ReactNode, ReactNode] | false\n  /** The value as words for screen readers. Default `${value} out of ${max}`. */\n  valueText?: (value: number) => string\n}\n\n// The ruler lives in a 240 × 74 box. Its pivot sits far below, so the arc is shallow.\nconst CX = 120\nconst CY = 214\nconst R = 200\nconst SPAN = 32\nconst TICKS = 41\nconst MAJOR = 5\nconst BOUNCE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'\nconst EASE = 'cubic-bezier(0.23, 1, 0.32, 1)'\nconst f2 = (v: number) => Number(v.toFixed(2))\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\n\n// A full drop from above the card on view and on new values; a short one for changes made by hand.\n// Damped so the bounce lands about a sixth of a digit past its line and never leaves the card.\nconst DROP = { from: '-240%', spring: { type: 'spring' as const, stiffness: 400, damping: 26, mass: 1 }, gap: 0.085 }\nconst NUDGE = { from: '-45%', spring: { type: 'spring' as const, stiffness: 760, damping: 38, mass: 0.7 }, gap: 0 }\n\nexport function ScoreDrop({\n  value,\n  onValueChange,\n  interactive,\n  title,\n  label,\n  caption,\n  min = 0,\n  max = 100,\n  step = 1,\n  glow = 'var(--primary)',\n  ends,\n  valueText,\n  className,\n  ...rest\n}: ScoreDropProps) {\n  const span = max - min || 1\n  const canAdjust = interactive ?? Boolean(onValueChange)\n  const controlled = !canAdjust || Boolean(onValueChange)\n  const [own, setOwn] = useState(() => clamp(value, min, max))\n  const v = clamp(controlled ? value : own, min, max)\n\n  const root = useRef<HTMLDivElement>(null)\n  const ruler = useRef<SVGSVGElement>(null)\n  const inView = useInView(root, { once: true, amount: 0.35 })\n  const reduced = useReducedMotion()\n  const seen = inView || reduced\n  const titleId = useId()\n  const captionId = useId()\n\n  // The last value set by a key or a drag. While the value is that one, the digits swap calmly instead of dropping\n  // from the top; a new value from outside drops in again.\n  const [hand, setHand] = useState<number | null>(null)\n  const byHand = hand !== null && hand === v\n  const dragging = useRef(false)\n\n  const decimals = (String(step).split('.')[1] ?? '').length\n  const snap = (x: number) => clamp(Number((min + Math.round((x - min) / step) * step).toFixed(decimals)), min, max)\n  const commit = (x: number) => {\n    const next = snap(x)\n    if (next === v) return\n    setHand(next)\n    if (!controlled) setOwn(next)\n    onValueChange?.(next)\n  }\n\n  const shown = seen ? v : min\n  const t = (shown - min) / span\n  const angle = -SPAN + 2 * SPAN * t\n  const text = valueText ? valueText(v) : `${f2(v)} out of ${max}`\n  const motionKind = byHand ? NUDGE : DROP\n\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const by: Record<string, number> = {\n      ArrowRight: step,\n      ArrowUp: step,\n      ArrowLeft: -step,\n      ArrowDown: -step,\n      PageUp: step * 10,\n      PageDown: -step * 10,\n    }\n    if (e.key === 'Home') commit(min)\n    else if (e.key === 'End') commit(max)\n    else if (e.key in by) commit(v + by[e.key])\n    else return\n    e.preventDefault()\n  }\n\n  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {\n    const svg = ruler.current\n    if (!svg) return\n    const box = svg.getBoundingClientRect()\n    const vb = svg.viewBox.baseVal\n    if (!box.width || !vb) return\n    const x = ((e.clientX - box.left) / box.width) * vb.width - CX\n    const y = CY - ((e.clientY - box.top) / box.height) * vb.height\n    const a = clamp((Math.atan2(x, Math.max(1, y)) * 180) / Math.PI, -SPAN, SPAN)\n    commit(min + ((a + SPAN) / (2 * SPAN)) * span)\n  }\n  const pointer = canAdjust\n    ? {\n        onPointerDown: (e: PointerEvent<HTMLDivElement>) => {\n          if (e.button !== 0) return\n          dragging.current = true\n          e.currentTarget.setPointerCapture(e.pointerId)\n          e.currentTarget.focus({ preventScroll: true })\n          fromPointer(e)\n        },\n        onPointerMove: (e: PointerEvent<HTMLDivElement>) => {\n          if (dragging.current) fromPointer(e)\n        },\n        onPointerUp: () => {\n          dragging.current = false\n        },\n        onPointerCancel: () => {\n          dragging.current = false\n        },\n      }\n    : {}\n\n  // A drag that ends outside the window still lets go.\n  useEffect(() => {\n    const up = () => {\n      dragging.current = false\n    }\n    window.addEventListener('pointerup', up)\n    return () => window.removeEventListener('pointerup', up)\n  }, [])\n\n  const role = canAdjust\n    ? { role: 'slider', tabIndex: 0, 'aria-orientation': 'horizontal' as const, onKeyDown: onKey }\n    : { role: 'meter' }\n\n  const still = reduced ? 'none' : undefined\n  const turn: CSSProperties = {\n    transformBox: 'view-box',\n    transformOrigin: `${CX}px ${CY}px`,\n    transform: `rotate(${f2(angle)}deg)`,\n    transition: still ?? (byHand ? `transform 140ms ${EASE}` : `transform 760ms ${BOUNCE} 120ms`),\n  }\n  const rise: CSSProperties = {\n    transform: `scaleY(${f2(0.3 + 0.7 * t)})`,\n    opacity: f2(seen ? 0.28 + 0.72 * t : 0),\n    transition: still ?? `transform 700ms ${EASE}, opacity 700ms ${EASE}`,\n  }\n\n  const chars = v.toFixed(decimals).split('')\n  const endLabels = ends === false ? null : (ends ?? [min, max])\n\n  return (\n    <div ref={root} className={cn('relative isolate w-full max-w-80', className)} {...rest}>\n      {/* The light that spills out under the card in dark mode. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-[10%] -bottom-4 -z-10 hidden h-2/5 rounded-full blur-2xl dark:block\"\n        style={{\n          background: `color-mix(in oklab, ${glow} 70%, transparent)`,\n          opacity: f2(seen ? 0.12 + 0.5 * t : 0),\n          transition: still ?? `opacity 700ms ${EASE}`,\n        }}\n      />\n      <div\n        className={cn(\n          // Near square: the card grows if a long title needs more room, never shrinks the number.\n          '@container relative flex aspect-[20/21] flex-col items-center overflow-hidden rounded-[calc(var(--radius)*2+2px)] bg-card px-5 pt-6 pb-5 text-card-foreground',\n          'shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]',\n          'forced-colors:border forced-colors:border-[CanvasText]',\n        )}\n      >\n        {/* The rising light. Dark mode only; its height and strength follow the value. */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 hidden h-[72%] origin-bottom dark:block\"\n          style={{\n            ...rise,\n            background: [\n              `linear-gradient(to top, color-mix(in oklab, ${glow} 55%, white) 0%, color-mix(in oklab, ${glow} 80%, transparent) 9%, transparent 30%)`,\n              `radial-gradient(130% 80% at 50% 100%, color-mix(in oklab, ${glow} 75%, transparent) 0%, color-mix(in oklab, ${glow} 28%, transparent) 45%, transparent 78%)`,\n            ].join(', '),\n          }}\n        />\n\n        <p\n          id={titleId}\n          className={cn(\n            'relative max-w-[17rem] text-center text-[14.5px] leading-[1.35] font-medium text-balance text-muted-foreground',\n            '[&_b]:font-semibold [&_b]:text-foreground [&_strong]:font-semibold [&_strong]:text-foreground',\n          )}\n        >\n          {title}\n        </p>\n\n        <div\n          {...role}\n          aria-label={label}\n          aria-labelledby={label ? undefined : titleId}\n          aria-describedby={caption ? captionId : undefined}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuenow={f2(v)}\n          aria-valuetext={text}\n          className={cn(\n            'relative mt-3 flex w-full flex-1 flex-col items-center rounded-2xl outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring',\n            canAdjust && 'cursor-ew-resize touch-none select-none',\n          )}\n          {...pointer}\n        >\n          <div aria-hidden className=\"relative w-full\">\n            <svg ref={ruler} viewBox=\"0 0 240 74\" className=\"block h-auto w-full overflow-visible\">\n              {Array.from({ length: TICKS }, (_, k) => {\n                const a = -SPAN + (2 * SPAN * k) / (TICKS - 1)\n                const major = k % MAJOR === 0\n                const len = major ? 15 : 9\n                const edge = Math.abs(k - (TICKS - 1) / 2) / ((TICKS - 1) / 2)\n                const fade = 1 - edge * edge * 0.65\n                const on = k / (TICKS - 1) <= t + 1e-9 && seen\n                return (\n                  <line\n                    key={k}\n                    x1={CX}\n                    x2={CX}\n                    y1={CY - R}\n                    y2={CY - R + len}\n                    stroke=\"var(--foreground)\"\n                    strokeWidth={major ? 1.6 : 1.1}\n                    strokeLinecap=\"round\"\n                    transform={`rotate(${f2(a)} ${CX} ${CY})`}\n                    style={{\n                      opacity: f2((on ? 0.8 : 0.2) * fade),\n                      transition: still ?? `opacity 240ms ${EASE} ${byHand ? 0 : Math.round(k * 9)}ms`,\n                    }}\n                  />\n                )\n              })}\n              <g style={turn}>\n                <line x1={CX} x2={CX} y1={CY - R - 6} y2={CY - R + 30} stroke=\"var(--foreground)\" strokeWidth={2.2} strokeLinecap=\"round\" />\n                <circle cx={CX} cy={CY - R - 6} r={3.4} fill={glow} />\n              </g>\n            </svg>\n            {endLabels && (\n              <div className=\"font-mono text-[11px] leading-none text-muted-foreground tabular-nums\">\n                <span className=\"absolute top-[70%] left-[5.5%] -translate-x-1/2\">{endLabels[0]}</span>\n                <span className=\"absolute top-[70%] left-[94.5%] -translate-x-1/2\">{endLabels[1]}</span>\n              </div>\n            )}\n          </div>\n\n          {caption && (\n            <p id={captionId} className=\"relative -mt-1 text-center text-[12.5px] leading-[1.3] text-muted-foreground\">\n              {caption}\n            </p>\n          )}\n\n          <span\n            aria-hidden\n            className={cn(\n              'relative mt-auto flex justify-center pt-2 text-[clamp(60px,36cqi,112px)] leading-none font-semibold tracking-[-0.045em] tabular-nums',\n              // In dark the digits pick up a little of the light at their feet.\n              'dark:[--sd-ink:linear-gradient(to_bottom,var(--foreground)_45%,color-mix(in_oklab,var(--sd-glow)_35%,var(--foreground)))]',\n            )}\n            style={{ ['--sd-glow' as string]: glow }}\n          >\n            {reduced ? (\n              <span className=\"dark:bg-(image:--sd-ink) dark:bg-clip-text dark:text-transparent\">{chars.join('')}</span>\n            ) : (\n              chars.map((ch, i) => {\n                // Count from the right, so the ones stay the ones when the score grows a digit.\n                const place = chars.length - i\n                return (\n                  <span key={place} className=\"relative inline-flex justify-center\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.span\n                        key={ch}\n                        initial={{ y: motionKind.from, opacity: 0 }}\n                        animate={seen ? { y: '0%', opacity: 1 } : { y: DROP.from, opacity: 0 }}\n                        exit={{ y: byHand ? '35%' : '60%', opacity: 0, transition: { duration: 0.16, ease: [0.23, 1, 0.32, 1] } }}\n                        transition={{\n                          y: { ...motionKind.spring, delay: i * motionKind.gap },\n                          opacity: { duration: 0.18, delay: i * motionKind.gap },\n                        }}\n                        className=\"inline-block dark:bg-(image:--sd-ink) dark:bg-clip-text dark:text-transparent\"\n                      >\n                        {ch}\n                      </motion.span>\n                    </AnimatePresence>\n                  </span>\n                )\n              })\n            )}\n          </span>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default ScoreDrop\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "meta": {
    "tier": "free",
    "added": "2026-10-14",
    "usedIn": [],
    "props": [
      {
        "name": "value",
        "type": "number",
        "default": null,
        "required": true,
        "description": "The score. With `onValueChange` it is controlled; with `interactive` alone it is only the starting value."
      },
      {
        "name": "onValueChange",
        "type": "(value: number) => void",
        "default": null,
        "required": false,
        "description": "Called with the new value when a key or a drag changes it. Passing it makes the card interactive."
      },
      {
        "name": "interactive",
        "type": "boolean",
        "default": null,
        "required": false,
        "description": "Turn the ruler into a slider you can drag or key. Default: true when `onValueChange` is set."
      },
      {
        "name": "title",
        "type": "ReactNode",
        "default": null,
        "required": true,
        "description": "The question or title at the top. Wrap words in `<strong>` to set them in full ink: `How <strong>calm</strong> was it?`"
      },
      {
        "name": "label",
        "type": "string",
        "default": null,
        "required": false,
        "description": "The accessible name, when the title is not plain enough to read out. Default: the title's text."
      },
      {
        "name": "caption",
        "type": "ReactNode",
        "default": null,
        "required": false,
        "description": "A small line under the ruler, such as \"out of 100\"."
      },
      {
        "name": "min",
        "type": "number",
        "default": "0",
        "required": false,
        "description": "The value at the left end of the ruler. Default 0."
      },
      {
        "name": "max",
        "type": "number",
        "default": "100",
        "required": false,
        "description": "The value at the right end of the ruler. Default 100."
      },
      {
        "name": "step",
        "type": "number",
        "default": "1",
        "required": false,
        "description": "How far one arrow key or one tick of a drag moves the value. Default 1."
      },
      {
        "name": "glow",
        "type": "string",
        "default": "'var(--primary)'",
        "required": false,
        "description": "Any CSS colour for the light that rises in dark mode and the needle's tip. Default `var(--primary)`."
      },
      {
        "name": "ends",
        "type": "[ReactNode, ReactNode] | false",
        "default": null,
        "required": false,
        "description": "The labels under the two ends of the ruler. Default `min` and `max`; false hides them."
      },
      {
        "name": "valueText",
        "type": "(value: number) => string",
        "default": null,
        "required": false,
        "description": "The value as words for screen readers. Default `${value} out of ${max}`."
      }
    ],
    "extends": "Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title' | 'onChange'>",
    "a11y": [
      {
        "topic": "keyboard",
        "text": "When it has onValueChange or interactive it is one Tab stop. ArrowRight and ArrowUp add a step, ArrowLeft and ArrowDown remove one, Page Up and Page Down move by ten steps, Home and End go to the ends. Dragging along the ruler sets the value.",
        "keys": [
          "Tab",
          "ArrowRight",
          "ArrowLeft",
          "ArrowUp",
          "ArrowDown",
          "PageUp",
          "PageDown",
          "Home",
          "End"
        ]
      },
      {
        "topic": "screen-reader",
        "text": "Read-only it is a meter; interactive it is a slider. It is named by the title (or label), described by the caption, and carries aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext (from valueText, default \"58 out of 100\"). The big digits, the ruler, the needle and the end labels are aria-hidden, so the score is read once."
      },
      {
        "topic": "motion",
        "text": "Under reduced motion it shows the final state at once: no drop, no needle swing, no rising light and no tick stagger. Otherwise the digits drop in when the card scrolls into view and when the value changes; changes made by key or drag use a short, calm swap instead of the full drop."
      },
      {
        "topic": "other",
        "text": "The rising light is dark mode only; light mode is a plain card. In forced colors mode the frame becomes a real border. The focus ring is a real outline around the ruler and number."
      }
    ]
  },
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}