{"slug":"prediction-market","name":"Prediction Market","description":"Prediction market trade ticket with buy/sell modes, outcome prices, rolling amount entry, quick add chips and trade states.","category":"blocks","source_url":"https://beui.dev/r/prediction-market/raw","detail_url":"https://beui.dev/r/prediction-market","raw_url":"https://beui.dev/r/prediction-market/raw","page_url":"https://beui.dev/components/blocks/prediction-market","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["./base","./button/stateful","./number-ticker","./tabs","@/components/motion/prediction-market","@/lib/ease","@/lib/hooks/use-hover-capable","@/lib/utils"],"files":[{"path":"components/motion/prediction-market.tsx","type":"component","content":"\"use client\";\n\nimport { Banknote, ChevronDown } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { StatefulButton, type ButtonState } from \"./button/stateful\";\nimport { NumberTicker } from \"./number-ticker\";\nimport { Tabs, TabsList, TabsTrigger } from \"./tabs\";\n\nexport type PredictionMarketMode = \"buy\" | \"sell\";\n\nexport type PredictionMarketOutcome = {\n  id: string;\n  label: string;\n  price: number;\n};\n\nexport type PredictionMarketOrderValue = {\n  mode: PredictionMarketMode;\n  outcomeId: string;\n  amount: string;\n};\n\nexport type PredictionMarketQuote = {\n  valid: boolean;\n  amount: number;\n  price: number;\n  shares: number;\n  payout: number;\n  error?: string;\n};\n\nexport type PredictionMarketClassNames = {\n  root?: string;\n  header?: string;\n  tabs?: string;\n  outcomes?: string;\n  amount?: string;\n  chips?: string;\n  footer?: string;\n  action?: string;\n};\n\nexport interface PredictionMarketProps {\n  outcomes?: PredictionMarketOutcome[];\n  value?: PredictionMarketOrderValue;\n  defaultValue?: Partial<PredictionMarketOrderValue>;\n  onValueChange?: (value: PredictionMarketOrderValue) => void;\n  onTrade?: (\n    order: PredictionMarketOrderValue,\n    quote: PredictionMarketQuote,\n  ) => void;\n  onSignIn?: () => void;\n  authenticated?: boolean;\n  orderTypeLabel?: string;\n  balance?: number;\n  positions?: Record<string, number>;\n  quickAmounts?: number[];\n  minTrade?: number;\n  className?: string;\n  classNames?: PredictionMarketClassNames;\n}\n\nconst DEFAULT_OUTCOMES: PredictionMarketOutcome[] = [\n  { id: \"up\", label: \"Up\", price: 0.09 },\n  { id: \"down\", label: \"Down\", price: 0.91 },\n];\n\nconst MODES: { id: PredictionMarketMode; label: string }[] = [\n  { id: \"buy\", label: \"Buy\" },\n  { id: \"sell\", label: \"Sell\" },\n];\n\nconst DEFAULT_QUICK_AMOUNTS = [10, 50, 100, 500];\nconst DIGIT_TRANSITION = { duration: 0.18, ease: EASE_OUT } as const;\ntype AmountInputStyle = CSSProperties & { \"--amount-chars\": string };\n\nfunction useControllableOrder({\n  value,\n  defaultValue,\n  outcomes,\n  onValueChange,\n}: {\n  value?: PredictionMarketOrderValue;\n  defaultValue?: Partial<PredictionMarketOrderValue>;\n  outcomes: PredictionMarketOutcome[];\n  onValueChange?: (value: PredictionMarketOrderValue) => void;\n}) {\n  const initialValue: PredictionMarketOrderValue = {\n    mode: defaultValue?.mode ?? \"buy\",\n    outcomeId: defaultValue?.outcomeId ?? outcomes[0]?.id ?? \"\",\n    amount: defaultValue?.amount ?? \"\",\n  };\n\n  const [internalValue, setInternalValue] = useState(initialValue);\n  const controlled = value !== undefined;\n  const order = value ?? internalValue;\n\n  const setOrder = useCallback(\n    (next: PredictionMarketOrderValue) => {\n      if (!controlled) {\n        setInternalValue(next);\n      }\n\n      onValueChange?.(next);\n    },\n    [controlled, onValueChange],\n  );\n\n  return [order, setOrder] as const;\n}\n\nfunction sanitizeAmount(value: string) {\n  const normalized = value.replace(/[^\\d.]/g, \"\");\n  const [whole, ...decimalParts] = normalized.split(\".\");\n  const decimal = decimalParts.join(\"\");\n  if (decimalParts.length === 0) return whole;\n  return `${whole}.${decimal.slice(0, 2)}`;\n}\n\nfunction parseAmount(value: string) {\n  return Number(value) || 0;\n}\n\nfunction formatCurrency(value: number, maximumFractionDigits = 2) {\n  return new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n    maximumFractionDigits,\n  }).format(value);\n}\n\nfunction formatCompactCurrency(value: number) {\n  return value >= 100\n    ? formatCurrency(value, 0)\n    : formatCurrency(value, value % 1 === 0 ? 0 : 2);\n}\n\nfunction formatCents(value: number) {\n  const cents = value * 100;\n  const precision = Number.isInteger(cents) ? 0 : 1;\n  return `${cents.toFixed(precision)}¢`;\n}\n\nfunction buildQuote({\n  order,\n  outcome,\n  balance,\n  position,\n  minTrade,\n}: {\n  order: PredictionMarketOrderValue;\n  outcome: PredictionMarketOutcome;\n  balance: number;\n  position: number;\n  minTrade: number;\n}): PredictionMarketQuote {\n  const amount = parseAmount(order.amount);\n  const price = Math.max(0.01, Math.min(0.99, outcome.price));\n  const shares = order.mode === \"buy\" ? amount / price : amount;\n  const payout = order.mode === \"buy\" ? shares : amount * price;\n\n  if (amount <= 0) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares: 0,\n      payout: 0,\n      error: \"Enter an amount\",\n    };\n  }\n\n  if (order.mode === \"buy\" && amount < minTrade) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares,\n      payout,\n      error: `Minimum ${formatCompactCurrency(minTrade)}`,\n    };\n  }\n\n  if (order.mode === \"buy\" && amount > balance) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares,\n      payout,\n      error: \"Insufficient balance\",\n    };\n  }\n\n  if (order.mode === \"sell\" && amount > position) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares,\n      payout,\n      error: \"Not enough shares\",\n    };\n  }\n\n  return {\n    valid: true,\n    amount,\n    price,\n    shares,\n    payout,\n  };\n}\n\nfunction keyedAmountChars(value: string) {\n  const seen = new Map<string, number>();\n  return value.split(\"\").map((char) => {\n    const count = seen.get(char) ?? 0;\n    seen.set(char, count + 1);\n    return { id: `${char}-${count}`, char };\n  });\n}\n\nfunction amountInputSize(value: string) {\n  const length = value.replace(/\\D/g, \"\").length;\n  if (length >= 10) return \"text-3xl sm:text-4xl\";\n  if (length >= 8) return \"text-4xl sm:text-5xl\";\n  if (length >= 6) return \"text-[44px] sm:text-[56px]\";\n  return \"text-5xl sm:text-6xl\";\n}\n\nfunction payoutTickerSize(value: number) {\n  const length = formatCurrency(value).length;\n  if (length >= 16) return \"text-xl sm:text-2xl\";\n  if (length >= 13) return \"text-2xl\";\n  if (length >= 10) return \"text-3xl\";\n  return \"text-4xl\";\n}\n\nfunction AnimatedAmountInput({\n  id,\n  value,\n  mode,\n  inputSize,\n  disabled,\n  reduce,\n  onChange,\n}: {\n  id: string;\n  value: string;\n  mode: PredictionMarketMode;\n  inputSize: string;\n  disabled: boolean;\n  reduce: boolean;\n  onChange: (value: string) => void;\n}) {\n  const displayValue = value || \"0\";\n  const chars = keyedAmountChars(displayValue);\n  const inputStyle = {\n    \"--amount-chars\": String(chars.length),\n  } as AmountInputStyle;\n  const label = mode === \"buy\" ? \"Amount\" : \"Shares\";\n\n  return (\n    <div className=\"flex min-w-0 items-center justify-center overflow-hidden\">\n      {mode === \"buy\" ? (\n        <span\n          aria-hidden\n          className={cn(\n            \"shrink-0 font-semibold leading-none tracking-normal text-muted-foreground/65 tabular-nums transition-[font-size] duration-200\",\n            inputSize,\n          )}\n        >\n          $\n        </span>\n      ) : null}\n\n      <div className=\"relative min-w-0 shrink\">\n        <input\n          id={id}\n          value={value}\n          disabled={disabled}\n          onChange={(event) => onChange(sanitizeAmount(event.target.value))}\n          placeholder=\"0\"\n          inputMode=\"decimal\"\n          aria-label={label}\n          autoComplete=\"off\"\n          className={cn(\n            \"w-[calc((var(--amount-chars)+1)*0.62em)] min-w-[0.8em] max-w-[260px] bg-transparent text-left font-semibold leading-none tracking-normal text-transparent outline-none tabular-nums\",\n            \"caret-foreground transition-[font-size] duration-200 placeholder:text-transparent selection:bg-foreground/10 disabled:cursor-not-allowed\",\n            inputSize,\n          )}\n          style={inputStyle}\n        />\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-0 flex min-w-0 items-center justify-start overflow-hidden font-semibold leading-none tracking-normal text-foreground tabular-nums transition-[font-size] duration-200\",\n            !value && \"text-muted-foreground/55\",\n            inputSize,\n          )}\n          style={inputStyle}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {chars.map(({ id: charId, char }) => (\n              <motion.span\n                key={charId}\n                layout={reduce ? false : \"position\"}\n                initial={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: 18, filter: \"blur(10px)\" }\n                }\n                animate={\n                  reduce\n                    ? { opacity: 1 }\n                    : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                }\n                exit={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: -14, filter: \"blur(10px)\" }\n                }\n                transition={DIGIT_TRANSITION}\n                className=\"inline-block min-w-[0.55em] text-center will-change-[transform,opacity,filter]\"\n              >\n                {char}\n              </motion.span>\n            ))}\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PredictionMarket({\n  outcomes = DEFAULT_OUTCOMES,\n  value,\n  defaultValue,\n  onValueChange,\n  onTrade,\n  onSignIn,\n  authenticated = true,\n  orderTypeLabel = \"Market\",\n  balance = 500,\n  positions = { up: 24, down: 16 },\n  quickAmounts = DEFAULT_QUICK_AMOUNTS,\n  minTrade = 1,\n  className,\n  classNames,\n}: PredictionMarketProps) {\n  const inputId = useId();\n  const reduce = useReducedMotion() ?? false;\n  const amountRef = useRef<HTMLDivElement>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const [status, setStatus] = useState<\"idle\" | \"placing\" | \"filled\">(\"idle\");\n  const [shakeKey, setShakeKey] = useState(0);\n  const [order, setOrder] = useControllableOrder({\n    value,\n    defaultValue,\n    outcomes,\n    onValueChange,\n  });\n\n  const selectedOutcome =\n    outcomes.find((outcome) => outcome.id === order.outcomeId) ?? outcomes[0];\n  const position = positions[selectedOutcome.id] ?? 0;\n  const quote = useMemo(\n    () =>\n      buildQuote({\n        order,\n        outcome: selectedOutcome,\n        balance,\n        position,\n        minTrade,\n      }),\n    [balance, minTrade, order, position, selectedOutcome],\n  );\n\n  const setOrderValue = useCallback(\n    (next: Partial<PredictionMarketOrderValue>) => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n        timeoutRef.current = null;\n      }\n\n      setStatus(\"idle\");\n      setOrder({ ...order, ...next });\n    },\n    [order, setOrder],\n  );\n\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (shakeKey === 0 || reduce || !amountRef.current) return;\n    animate(\n      amountRef.current,\n      { x: [0, -5, 5, -3, 3, -1, 0] },\n      { duration: 0.38, ease: EASE_OUT },\n    );\n  }, [reduce, shakeKey]);\n\n  const addAmount = (increment: number) => {\n    const next = parseAmount(order.amount) + increment;\n    setOrderValue({ amount: String(next) });\n  };\n\n  const setMax = () => {\n    if (order.mode === \"buy\") {\n      setOrderValue({ amount: String(Math.floor(balance)) });\n      return;\n    }\n\n    setOrderValue({ amount: position.toFixed(position % 1 === 0 ? 0 : 2) });\n  };\n\n  const submit = () => {\n    if (!authenticated) {\n      onSignIn?.();\n      return;\n    }\n\n    if (!quote.valid) {\n      setShakeKey((key) => key + 1);\n      return;\n    }\n\n    setStatus(\"placing\");\n    timeoutRef.current = setTimeout(() => {\n      setStatus(\"filled\");\n      onTrade?.(order, quote);\n    }, 650);\n  };\n\n  const inputSize = amountInputSize(order.amount);\n  const payoutSize = payoutTickerSize(quote.payout);\n  const actionState: ButtonState =\n    status === \"placing\"\n      ? \"loading\"\n      : status === \"filled\"\n        ? \"success\"\n        : quote.valid\n          ? \"idle\"\n          : \"error\";\n  const showFooter = authenticated;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-[400px] overflow-hidden rounded-3xl border border-border bg-background\",\n        className,\n        classNames?.root,\n      )}\n    >\n      <div\n        className={cn(\n          \"border-b border-border/80 px-4 pt-4\",\n          classNames?.header,\n        )}\n      >\n        <div className=\"flex items-end justify-between gap-4\">\n          <Tabs\n            value={order.mode}\n            onValueChange={(mode) =>\n              setOrderValue({\n                mode: mode as PredictionMarketMode,\n                amount: \"\",\n              })\n            }\n            variant=\"underline\"\n            className={cn(\"shrink-0\", classNames?.tabs)}\n          >\n            <TabsList className=\"gap-5 border-b-0 bg-transparent p-0\">\n              {MODES.map((mode) => (\n                <TabsTrigger\n                  key={mode.id}\n                  value={mode.id}\n                  className=\"px-0 pb-3 pt-0 text-2xl font-semibold\"\n                  indicatorClassName=\"h-0.5 bg-foreground\"\n                >\n                  {mode.label}\n                </TabsTrigger>\n              ))}\n            </TabsList>\n          </Tabs>\n\n          <button\n            type=\"button\"\n            disabled={status === \"placing\"}\n            className=\"mb-3 inline-flex items-center gap-2 text-xl font-semibold text-foreground transition-opacity disabled:opacity-50\"\n          >\n            {orderTypeLabel}\n            <ChevronDown className=\"h-5 w-5\" />\n          </button>\n        </div>\n      </div>\n\n      <div className=\"space-y-4 p-3\">\n        <Tabs\n          value={selectedOutcome.id}\n          onValueChange={(outcomeId) => setOrderValue({ outcomeId })}\n          variant=\"pill\"\n          className={classNames?.outcomes}\n        >\n          <TabsList className=\"grid w-full grid-cols-2 gap-2 p-1.5\">\n            {outcomes.map((outcome) => {\n              const selected = outcome.id === selectedOutcome.id;\n              const isNo =\n                outcome.label.toLowerCase() === \"no\" ||\n                outcome.label.toLowerCase() === \"down\";\n\n              return (\n                <TabsTrigger\n                  key={outcome.id}\n                  value={outcome.id}\n                  indicatorClassName={\n                    isNo\n                      ? \"bg-red-500/10 dark:bg-red-500/15\"\n                      : \"bg-emerald-500/20\"\n                  }\n                  className={cn(\n                    \"h-14 w-full rounded-[1.35rem] px-0 py-0 text-base font-semibold active:scale-[0.99]\",\n                    isNo\n                      ? selected\n                        ? \"text-red-300 dark:text-red-300\"\n                        : \"text-red-300/55 dark:text-red-300/50\"\n                      : selected\n                        ? \"text-emerald-400 dark:text-emerald-300\"\n                        : \"text-muted-foreground\",\n                  )}\n                >\n                  {outcome.label} {formatCents(outcome.price)}\n                </TabsTrigger>\n              );\n            })}\n          </TabsList>\n        </Tabs>\n\n        <div\n          ref={amountRef}\n          className={cn(\"rounded-3xl bg-card p-4\", classNames?.amount)}\n        >\n          <div className=\"flex min-h-24 flex-col items-center justify-center gap-5 text-center\">\n            <label\n              htmlFor={inputId}\n              className=\"text-xl font-medium text-foreground mr-6\"\n            >\n              {order.mode === \"buy\" ? \"Amount\" : \"Shares\"}\n            </label>\n\n            <div className=\"w-full min-w-0\">\n              <AnimatedAmountInput\n                id={inputId}\n                mode={order.mode}\n                value={order.amount}\n                disabled={status === \"placing\"}\n                inputSize={inputSize}\n                reduce={reduce}\n                onChange={(amount) => setOrderValue({ amount })}\n              />\n            </div>\n          </div>\n\n          <div\n            className={cn(\n              \"mt-8 flex flex-wrap justify-center gap-2\",\n              classNames?.chips,\n            )}\n          >\n            {quickAmounts.map((amount) => (\n              <button\n                key={amount}\n                type=\"button\"\n                disabled={status === \"placing\"}\n                onClick={() => addAmount(amount)}\n                className=\"h-9 rounded-xl bg-background px-3.5 text-sm font-semibold text-foreground transition-[background-color,transform] duration-150 active:scale-95 disabled:pointer-events-none disabled:opacity-50\"\n              >\n                +{order.mode === \"buy\" ? formatCompactCurrency(amount) : amount}\n              </button>\n            ))}\n            <button\n              type=\"button\"\n              disabled={status === \"placing\"}\n              onClick={setMax}\n              className=\"h-9 rounded-xl bg-background px-3.5 text-sm font-semibold text-foreground transition-[background-color,transform] duration-150 active:scale-95 disabled:pointer-events-none disabled:opacity-50\"\n            >\n              Max\n            </button>\n          </div>\n        </div>\n      </div>\n\n      {showFooter ? (\n        <div\n          className={cn(\n            \"border-t border-border/80 px-4 py-4\",\n            classNames?.footer,\n          )}\n        >\n          <div className=\"mb-4 flex items-end justify-between gap-3\">\n            <div className=\"min-w-0 shrink\">\n              <div className=\"flex items-center gap-2 text-xl font-semibold text-foreground\">\n                {order.mode === \"buy\" ? \"To win\" : \"To receive\"}\n                <Banknote className=\"h-5 w-5 text-emerald-500\" />\n              </div>\n              <p className=\"text-sm font-medium text-muted-foreground\">\n                Avg. Price {formatCents(quote.price)}\n              </p>\n            </div>\n            <NumberTicker\n              value={quote.payout * 100}\n              startOnView={false}\n              duration={0.45}\n              stagger={0}\n              blur\n              className={cn(\n                \"ml-auto min-w-0 shrink-0 justify-end whitespace-nowrap text-right font-semibold leading-none tracking-tight text-emerald-500 tabular-nums transition-[font-size] duration-200\",\n                payoutSize,\n              )}\n              format={(cents) => formatCurrency(cents / 100)}\n            />\n          </div>\n\n          <StatefulButton\n            state={actionState}\n            variant=\"primary\"\n            size=\"lg\"\n            pressScale={0.98}\n            onClick={submit}\n            loadingText=\"Trading\"\n            successText=\"Trade filled\"\n            errorText={quote.error ?? \"Enter an amount\"}\n            className={cn(\n              \"h-12 w-full rounded-2xl text-base font-semibold\",\n              classNames?.action,\n            )}\n          >\n            Trade\n          </StatefulButton>\n        </div>\n      ) : (\n        <div className=\"px-4 pb-5\">\n          <StatefulButton\n            state=\"idle\"\n            variant=\"primary\"\n            size=\"lg\"\n            pressScale={0.98}\n            onClick={submit}\n            className={cn(\n              \"h-14 w-full rounded-2xl text-base font-semibold\",\n              classNames?.action,\n            )}\n          >\n            Sign In\n          </StatefulButton>\n        </div>\n      )}\n    </div>\n  );\n}\n"},{"path":"components/motion/button/stateful.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  forwardRef,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, EASE_OUT_CSS, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  initial: { opacity: 0, y: 14, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    y: -14,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center will-change-[opacity,filter,transform]\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((currentWidth) =>\n      currentWidth === nextWidth ? currentWidth : nextWidth,\n    );\n  });\n\n  return (\n    <span\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n      style={{\n        width,\n        transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,\n      }}\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} {...rest}>\n      <motion.span\n        layout={!reduce}\n        transition={SPRING_SWAP}\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center gap-2 overflow-hidden\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </motion.span>\n    </Button>\n  );\n});\n"},{"path":"components/motion/number-ticker.tsx","type":"util","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString()\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/tabs.tsx","type":"util","content":"\"use client\";\n\nimport { motion, MotionConfig, useReducedMotion, type Transition } from \"motion/react\";\nimport { createContext, useContext, useId, useState, type ReactNode } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Variant = \"pill\" | \"underline\" | \"segment\";\n\ntype Ctx = {\n  value: string;\n  setValue: (v: string) => void;\n  layoutId: string;\n  variant: Variant;\n};\n\nconst TabsCtx = createContext<Ctx | null>(null);\n\nfunction useTabs() {\n  const ctx = useContext(TabsCtx);\n  if (!ctx) throw new Error(\"Tabs.* must be used inside <Tabs>\");\n  return ctx;\n}\n\n// Weighty spring — borrowed from dimi.me/lab/animated-tabs.\nconst transition: Transition = {\n  type: \"spring\",\n  stiffness: 170,\n  damping: 24,\n  mass: 1.2,\n};\n\nexport function Tabs({\n  defaultValue,\n  value,\n  onValueChange,\n  variant = \"pill\",\n  children,\n  className,\n}: {\n  defaultValue?: string;\n  value?: string;\n  onValueChange?: (v: string) => void;\n  variant?: Variant;\n  children: ReactNode;\n  className?: string;\n}) {\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const layoutId = useId();\n  const reduce = useReducedMotion();\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const setValue = (v: string) => {\n    if (!controlled) setInternal(v);\n    onValueChange?.(v);\n  };\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : transition}>\n      <TabsCtx.Provider value={{ value: current, setValue, layoutId, variant }}>\n        {/* layoutRoot: the indicator's layoutId measures in page coordinates, so\n            inside fixed/scrolled containers it would replay scroll offsets as\n            movement. The pill only ever travels within the list, so scoping\n            projection to the Tabs wrapper is always correct. */}\n        <motion.div layoutRoot className={className}>\n          {children}\n        </motion.div>\n      </TabsCtx.Provider>\n    </MotionConfig>\n  );\n}\n\nconst listClasses: Record<Variant, string> = {\n  pill: \"inline-flex items-center gap-1 rounded-full bg-card p-1\",\n  underline: \"inline-flex items-center gap-1 border-b border-border\",\n  segment: \"inline-flex items-center gap-0 rounded-lg bg-card p-0.5\",\n};\n\nexport function TabsList({ children, className }: { children: ReactNode; className?: string }) {\n  const { variant } = useTabs();\n  return (\n    <div role=\"tablist\" className={cn(listClasses[variant], className)}>\n      {children}\n    </div>\n  );\n}\n\nexport function TabsTrigger({\n  value,\n  children,\n  className,\n  indicatorClassName,\n}: {\n  value: string;\n  children: ReactNode;\n  className?: string;\n  indicatorClassName?: string;\n}) {\n  const { value: current, setValue, layoutId, variant } = useTabs();\n  const active = current === value;\n\n  if (variant === \"underline\") {\n    return (\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors\",\n          active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          className,\n        )}\n      >\n        {children}\n        {active ? (\n        <motion.span\n          layoutId={layoutId}\n          className={cn(\n            \"absolute -bottom-px left-0 right-0 h-px bg-primary\",\n            indicatorClassName,\n          )}\n        />\n        ) : null}\n      </button>\n    );\n  }\n\n  // Pill + Segment use the same trick: a max-contrast pill slides via layoutId,\n  // text uses `mix-blend-exclusion` so it inverts dynamically against the moving bg.\n  const radius = variant === \"pill\" ? \"rounded-full\" : \"rounded-md\";\n\n  return (\n    <div className=\"relative\">\n      {active ? (\n        <motion.span\n          layoutId={layoutId}\n          style={{ borderRadius: variant === \"pill\" ? 9999 : 8 }}\n          className={cn(\n            \"absolute inset-0 bg-primary\",\n            radius,\n            indicatorClassName,\n          )}\n        />\n      ) : null}\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium transition-colors outline-none\",\n          active ? \"text-primary-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          radius,\n          className,\n        )}\n      >\n        {children}\n      </button>\n    </div>\n  );\n}\n\nexport function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {\n  const { value: current } = useTabs();\n  const reduce = useReducedMotion();\n  if (current !== value) return null;\n  return (\n    <motion.div\n      key={value}\n      initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.18, ease: EASE_OUT }}\n      className={cn(\"mt-4\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/button/base.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion, type HTMLMotionProps } from \"motion/react\";\nimport { forwardRef, type ReactNode } from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<HTMLMotionProps<\"button\">, \"children\"> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary:\n    \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(\n  { variant = \"primary\", size = \"md\", pressScale = 0.93, className, children, ...rest },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      whileTap={reduce ? undefined : { scale: pressScale }}\n      whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center font-medium select-none\",\n        \"transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </motion.button>\n  );\n});\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/previews/blocks/prediction-market.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  PredictionMarket,\n  type PredictionMarketOrderValue,\n} from \"@/components/motion/prediction-market\";\n\nconst outcomes = [\n  {\n    id: \"yes\",\n    label: \"Yes\",\n    price: 0.167,\n  },\n  {\n    id: \"no\",\n    label: \"No\",\n    price: 0.834,\n  },\n];\n\nexport function PredictionMarketPreview() {\n  const [order, setOrder] = useState<PredictionMarketOrderValue>({\n    mode: \"buy\",\n    outcomeId: \"yes\",\n    amount: \"115\",\n  });\n\n  return (\n    <div className=\"flex w-full items-center justify-center\">\n      <PredictionMarket\n        outcomes={outcomes}\n        value={order}\n        onValueChange={setOrder}\n        balance={500}\n        positions={{ yes: 125, no: 48 }}\n        quickAmounts={[1, 5, 10, 100]}\n      />\n    </div>\n  );\n}\n"}]}