{
  "name": "tip-selector",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Tip Selector\n * @version: 2.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Check, Heart } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { type PointerEvent, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** Width of one percent on the tape, in px. */\nconst TICK = 16;\nconst SETTLE = { type: \"spring\", stiffness: 420, damping: 42 } as const;\n\nexport interface TipSelectorProps {\n  /** The order before the tip. */\n  subtotal?: number;\n  /** Who the tip goes to. */\n  recipient?: { name: string; note?: string };\n  /** Marked on the tape and offered as shortcuts, in percent. */\n  percents?: number[];\n  /** Where the tape starts. */\n  defaultPercent?: number;\n  /** The far end of the tape, in percent. */\n  max?: number;\n  currency?: string;\n  onChange?: (tip: number) => void;\n  onPay?: (total: number) => void;\n  className?: string;\n}\n\nconst round2 = (n: number) => Math.round(n * 100) / 100;\nconst clamp = (n: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, n));\n\nconst fmt = (n: number, currency: string) =>\n  `${currency}${n.toLocaleString(\"en-US\", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;\n\n/** Past either end the tape gives, but less and less. */\nconst rubber = (value: number, max: number) => {\n  if (value < 0) {\n    return -Math.sqrt(-value) * 0.9;\n  }\n  return value > max ? max + Math.sqrt(value - max) * 0.9 : value;\n};\n\n/**\n * A money amount whose digits roll: up when it grows, down when it shrinks.\n * Columns are keyed from the right, so cents stay over cents.\n */\nfunction Rolling({\n  value,\n  currency,\n  reduceMotion,\n  className,\n}: {\n  value: number;\n  currency: string;\n  reduceMotion: boolean;\n  className?: string;\n}) {\n  const [last, setLast] = useState(value);\n  const [direction, setDirection] = useState(1);\n  if (last !== value) {\n    setDirection(value > last ? 1 : -1);\n    setLast(value);\n  }\n  const text = fmt(value, currency);\n  const chars = [...text];\n  return (\n    <span className={cn(\"inline-flex tabular-nums\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      {chars.map((char, i) => {\n        const column = chars.length - i;\n        return (\n          <span\n            aria-hidden=\"true\"\n            className=\"relative inline-block overflow-hidden\"\n            key={column}\n          >\n            <AnimatePresence\n              custom={direction}\n              initial={false}\n              mode=\"popLayout\"\n            >\n              <motion.span\n                animate={{ y: \"0%\", opacity: 1 }}\n                className=\"inline-block\"\n                exit={{ y: `${-70 * direction}%`, opacity: 0 }}\n                initial={{ y: `${70 * direction}%`, opacity: 0 }}\n                key={char}\n                transition={\n                  reduceMotion\n                    ? { duration: 0 }\n                    : { duration: 0.22, ease: EASE_OUT, delay: column * 0.01 }\n                }\n              >\n                {char}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Ticks({ max, percents }: { max: number; percents: number[] }) {\n  const ticks: number[] = [];\n  for (let i = 0; i <= max; i++) {\n    ticks.push(i);\n  }\n  return (\n    <>\n      {ticks.map((i) => {\n        const major = i % 5 === 0;\n        const marked = percents.includes(i);\n        return (\n          <span\n            className={cn(\n              \"absolute bottom-7 w-px -translate-x-1/2 rounded-full bg-current\",\n              major || marked ? \"h-6\" : \"h-3\"\n            )}\n            key={i}\n            style={{ left: i * TICK }}\n          />\n        );\n      })}\n    </>\n  );\n}\n\n/**\n * A tip picker for checkout, set on a tape you drag. Flick it and it coasts\n * to a whole percent; the ticks you've passed fill in, the tip and total roll,\n * and Round up picks the tip that makes the total a whole amount.\n */\nexport default function TipSelector({\n  subtotal = 42.6,\n  recipient = { name: \"Sam\", note: \"Your courier · 6 min early\" },\n  percents = [15, 18, 20],\n  defaultPercent = 18,\n  max = 30,\n  currency = \"$\",\n  onChange,\n  onPay,\n  className,\n}: TipSelectorProps) {\n  const [percent, setPercent] = useState(defaultPercent);\n  // Round up sets the tip itself, so the total lands on a whole amount.\n  const [exactTip, setExactTip] = useState<number | null>(null);\n  const [dragging, setDragging] = useState(false);\n  const [paid, setPaid] = useState(false);\n  const reduceMotion = Boolean(useReducedMotion());\n\n  const position = useMotionValue(defaultPercent);\n  const x = useTransform(position, (v) => -v * TICK);\n  const filled = useTransform(\n    position,\n    (v) => `inset(0px ${(max - clamp(v, 0, max)) * TICK}px 0px -2px)`\n  );\n  const drag = useRef({ x: 0, from: 0, v: 0, t: 0, lastX: 0 });\n\n  const tip = exactTip ?? round2((subtotal * percent) / 100);\n  const total = round2(subtotal + tip);\n  const roundTip = round2(Math.floor(subtotal + tip) + 1 - subtotal);\n\n  const commit = (next: number, exact: number | null = null) => {\n    setPercent(next);\n    setExactTip(exact);\n    setPaid(false);\n    onChange?.(exact ?? round2((subtotal * next) / 100));\n  };\n\n  const glide = (target: number, exact: number | null = null) => {\n    commit(Math.round(target), exact);\n    if (reduceMotion) {\n      position.set(target);\n      return;\n    }\n    animate(position, target, SETTLE);\n  };\n\n  const onDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) {\n      return;\n    }\n    position.stop();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = {\n      x: event.clientX,\n      from: position.get(),\n      v: 0,\n      t: event.timeStamp,\n      lastX: event.clientX,\n    };\n    setDragging(true);\n  };\n\n  const onMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (!dragging) {\n      return;\n    }\n    const d = drag.current;\n    const dt = Math.max(1, event.timeStamp - d.t);\n    d.v = d.v * 0.6 + ((event.clientX - d.lastX) / dt) * 0.4;\n    d.t = event.timeStamp;\n    d.lastX = event.clientX;\n    const raw = d.from - (event.clientX - d.x) / TICK;\n    position.set(rubber(raw, max));\n    const next = clamp(Math.round(raw), 0, max);\n    if (next !== percent || exactTip !== null) {\n      commit(next);\n    }\n  };\n\n  const onUp = () => {\n    if (!dragging) {\n      return;\n    }\n    setDragging(false);\n    // Carry the flick a little way, then settle on a whole percent.\n    const coast = (-drag.current.v * 160) / TICK;\n    glide(clamp(Math.round(position.get() + coast), 0, max));\n  };\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-sm rounded-3xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950\",\n        className\n      )}\n    >\n      <div className=\"flex items-center gap-3\">\n        <span className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-orange-100 font-medium text-orange-700 text-xs dark:bg-orange-500/15 dark:text-orange-300\">\n          {recipient.name.slice(0, 1)}\n        </span>\n        <div className=\"min-w-0 flex-1 leading-tight\">\n          <p className=\"truncate font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n            Tip {recipient.name}\n          </p>\n          {recipient.note && (\n            <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">\n              {recipient.note}\n            </p>\n          )}\n        </div>\n      </div>\n\n      <div className=\"mt-5 flex flex-col items-center\">\n        <Rolling\n          className=\"font-semibold text-4xl text-zinc-900 tracking-tight dark:text-zinc-50\"\n          currency={currency}\n          reduceMotion={reduceMotion}\n          value={tip}\n        />\n        <p className=\"mt-1 h-4 text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n          {tip === 0\n            ? \"No tip\"\n            : `${exactTip === null ? percent : ((tip / subtotal) * 100).toFixed(1)}% of ${fmt(subtotal, currency)}`}\n        </p>\n      </div>\n\n      {/* The tape. A range input underneath keeps it on the keyboard. */}\n      <div\n        className={cn(\n          \"relative mt-4 h-20 touch-none select-none overflow-hidden rounded-2xl text-zinc-300 outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-zinc-400 dark:text-zinc-700\",\n          dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n        )}\n        onPointerCancel={onUp}\n        onPointerDown={onDown}\n        onPointerMove={onMove}\n        onPointerUp={onUp}\n        style={{\n          maskImage:\n            \"linear-gradient(to right, transparent, black 22%, black 78%, transparent)\",\n        }}\n      >\n        <input\n          aria-label={`Tip for ${recipient.name}, in percent`}\n          aria-valuetext={`${percent}%, ${fmt(tip, currency)}`}\n          className=\"sr-only\"\n          max={max}\n          min={0}\n          onChange={(event) => glide(Number(event.target.value))}\n          step={1}\n          type=\"range\"\n          value={percent}\n        />\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"absolute inset-y-0 left-1/2\"\n          style={{ x, width: max * TICK }}\n        >\n          <Ticks max={max} percents={percents} />\n          <motion.div\n            className=\"absolute inset-0 text-zinc-900 dark:text-zinc-100\"\n            style={{ clipPath: filled }}\n          >\n            <Ticks max={max} percents={percents} />\n          </motion.div>\n          {[...new Set([0, ...percents, max])].map((p) => (\n            <span\n              className={cn(\n                \"absolute bottom-2 -translate-x-1/2 text-[10px] tabular-nums\",\n                p === percent\n                  ? \"text-zinc-900 dark:text-zinc-100\"\n                  : \"text-zinc-400 dark:text-zinc-500\"\n              )}\n              key={p}\n              style={{ left: p * TICK }}\n            >\n              {p}%\n            </span>\n          ))}\n        </motion.div>\n        {/* The needle. */}\n        <span className=\"pointer-events-none absolute top-3 bottom-7 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-orange-500\" />\n      </div>\n\n      <div className=\"mt-3 grid grid-cols-4 gap-1.5\">\n        {percents.map((p) => (\n          <button\n            aria-pressed={exactTip === null && percent === p}\n            className={cn(\n              \"h-8 rounded-full font-medium text-xs tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-zinc-400\",\n              exactTip === null && percent === p\n                ? \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n                : \"bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n            )}\n            key={p}\n            onClick={() => glide(p)}\n            type=\"button\"\n          >\n            {p}%\n          </button>\n        ))}\n        <button\n          aria-pressed={exactTip !== null}\n          className={cn(\n            \"h-8 rounded-full font-medium text-xs outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-zinc-400\",\n            exactTip === null\n              ? \"bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n              : \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n          )}\n          onClick={() => glide((roundTip / subtotal) * 100, roundTip)}\n          title={`Round the total up to ${fmt(subtotal + roundTip, currency)}`}\n          type=\"button\"\n        >\n          Round up\n        </button>\n      </div>\n\n      <dl className=\"mt-5 space-y-1.5 border-zinc-100 border-t pt-4 text-sm dark:border-zinc-900\">\n        <div className=\"flex justify-between text-zinc-500 dark:text-zinc-400\">\n          <dt>Subtotal</dt>\n          <dd className=\"tabular-nums\">{fmt(subtotal, currency)}</dd>\n        </div>\n        <div className=\"flex justify-between text-zinc-500 dark:text-zinc-400\">\n          <dt className=\"flex items-center gap-1.5\">\n            Tip\n            <Heart\n              aria-hidden\n              className=\"size-3 text-rose-500\"\n              fill=\"currentColor\"\n            />\n            <span className=\"text-xs\">all to {recipient.name}</span>\n          </dt>\n          <dd className=\"tabular-nums\">{fmt(tip, currency)}</dd>\n        </div>\n        <div className=\"flex items-baseline justify-between pt-1 text-zinc-900 dark:text-zinc-50\">\n          <dt className=\"font-medium\">Total</dt>\n          <dd>\n            <Rolling\n              className=\"font-semibold text-xl tracking-tight\"\n              currency={currency}\n              reduceMotion={reduceMotion}\n              value={total}\n            />\n          </dd>\n        </div>\n      </dl>\n\n      <button\n        className={cn(\n          \"mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-xl font-medium text-sm outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n          paid\n            ? \"bg-emerald-600 text-white dark:bg-emerald-500 dark:text-emerald-950\"\n            : \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white\"\n        )}\n        onClick={() => {\n          if (paid) {\n            return;\n          }\n          onPay?.(total);\n          setPaid(true);\n        }}\n        type=\"button\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"inline-flex items-center gap-2\"\n            exit={{ opacity: 0, y: -8 }}\n            initial={{ opacity: 0, y: 8 }}\n            key={paid ? \"paid\" : \"pay\"}\n            transition={{ duration: reduceMotion ? 0 : 0.18, ease: EASE_OUT }}\n          >\n            {paid ? (\n              <>\n                <Check aria-hidden className=\"size-4\" strokeWidth={2.5} />\n                Paid, thank you\n              </>\n            ) : (\n              \"Place order\"\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </button>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/tip-selector.tsx",
      "target": "components/kokonutui/tip-selector.tsx"
    }
  ]
}