{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A SwapPanel component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { ArrowLeftRight, ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\n\nconst SPRING_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst SHIMMER_DURATION = 1.4;\nconst SHIMMER_EASE = [0, 0, 1, 1] as const;\nconst RING_SIZE = 20;\nconst RING_RADIUS = 8;\nconst RING_STROKE = 2;\nconst RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;\nconst RING_TICK_MS = 100;\n\nconst decimalFormatter = new Intl.NumberFormat(\"en-US\", {\n  maximumFractionDigits: 6,\n});\nconst percentFormatter = new Intl.NumberFormat(\"en-US\", {\n  maximumFractionDigits: 2,\n  style: \"percent\",\n});\n\nexport interface SwapToken {\n  balance?: number;\n  icon?: ReactNode;\n  name: string;\n  symbol: string;\n}\n\nexport interface SwapQuote {\n  expiresAt?: number;\n  fee?: number;\n  output: number;\n  priceImpact?: number;\n  rate: number;\n}\n\nexport type SwapStatus = \"idle\" | \"quoting\" | \"ready\" | \"submitting\" | \"error\";\n\nexport interface SwapPanelProps {\n  amount: string;\n  className?: string;\n  error?: string;\n  from: SwapToken;\n  onAmountChange: (value: string) => void;\n  onFlip: () => void;\n  onSelectToken?: (side: \"from\" | \"to\") => void;\n  onSubmit: () => void;\n  quote?: SwapQuote;\n  slippage?: number;\n  status: SwapStatus;\n  to: SwapToken;\n}\n\nconst RollingFigure = ({\n  shouldReduceMotion,\n  value,\n}: {\n  shouldReduceMotion: boolean;\n  value: string;\n}) => (\n  <span className=\"relative inline-flex overflow-hidden\">\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        animate={{ opacity: 1, y: 0 }}\n        exit={\n          shouldReduceMotion\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { opacity: 0, y: -12 }\n        }\n        initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 12 }}\n        key={value}\n        transition={shouldReduceMotion ? { duration: 0 } : SPRING_TRANSITION}\n      >\n        {value}\n      </motion.span>\n    </AnimatePresence>\n  </span>\n);\n\nconst CountdownRing = ({\n  expiresAt,\n  shouldReduceMotion,\n}: {\n  expiresAt: number;\n  shouldReduceMotion: boolean;\n}) => {\n  const startRef = useRef(Date.now());\n  const [now, setNow] = useState(() => Date.now());\n\n  useEffect(() => {\n    startRef.current = Date.now();\n    setNow(Date.now());\n    const interval = setInterval(() => setNow(Date.now()), RING_TICK_MS);\n    return () => clearInterval(interval);\n  }, [expiresAt]);\n\n  const totalMs = expiresAt - startRef.current;\n  const remainingMs = Math.max(expiresAt - now, 0);\n  const fraction = totalMs > 0 ? remainingMs / totalMs : 0;\n  // Exception to the transform/opacity-only rule: a radial countdown needs to animate\n  // `stroke-dashoffset`, a paint-only SVG property with no reasonable transform equivalent.\n  // This is the standard low-cost technique for progress rings.\n  const dashOffset = RING_CIRCUMFERENCE * (1 - fraction);\n\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"shrink-0\"\n      height={RING_SIZE}\n      viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}\n      width={RING_SIZE}\n    >\n      <circle\n        className=\"text-muted-foreground/20\"\n        cx={RING_SIZE / 2}\n        cy={RING_SIZE / 2}\n        fill=\"none\"\n        r={RING_RADIUS}\n        stroke=\"currentColor\"\n        strokeWidth={RING_STROKE}\n      />\n      <circle\n        className=\"text-brand\"\n        cx={RING_SIZE / 2}\n        cy={RING_SIZE / 2}\n        fill=\"none\"\n        r={RING_RADIUS}\n        stroke=\"currentColor\"\n        strokeDasharray={RING_CIRCUMFERENCE}\n        strokeDashoffset={dashOffset}\n        strokeLinecap=\"round\"\n        strokeWidth={RING_STROKE}\n        style={{\n          transform: \"rotate(-90deg)\",\n          transformOrigin: \"50% 50%\",\n          transition: shouldReduceMotion\n            ? \"none\"\n            : `stroke-dashoffset ${RING_TICK_MS}ms linear`,\n        }}\n      />\n    </svg>\n  );\n};\n\nconst TokenChip = ({\n  onSelect,\n  side,\n  token,\n}: {\n  onSelect?: () => void;\n  side: \"from\" | \"to\";\n  token: SwapToken;\n}) => (\n  <SmoothButton\n    aria-label={`Change ${side === \"from\" ? \"pay\" : \"receive\"} token, currently ${token.symbol}`}\n    className=\"shrink-0 text-foreground\"\n    onClick={onSelect}\n    prefix={\n      token.icon ?? (\n        <span\n          aria-hidden=\"true\"\n          className=\"flex h-5 w-5 items-center justify-center rounded-full bg-foreground/10 font-semibold text-[10px] text-foreground\"\n        >\n          {token.symbol.charAt(0)}\n        </span>\n      )\n    }\n    shape=\"pill\"\n    size=\"sm\"\n    suffix={\n      <ChevronDown aria-hidden=\"true\" className=\"text-muted-foreground\" />\n    }\n    variant=\"secondary\"\n  >\n    {token.symbol}\n  </SmoothButton>\n);\n\nexport default function SwapPanel({\n  amount,\n  className = \"\",\n  error,\n  from,\n  onAmountChange,\n  onFlip,\n  onSelectToken,\n  onSubmit,\n  quote,\n  slippage,\n  status,\n  to,\n}: SwapPanelProps) {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const [flipped, setFlipped] = useState(false);\n  const fromInputId = useId();\n  const toLabelId = useId();\n\n  const isBusy = status === \"quoting\" || status === \"submitting\";\n  const outputFormatted = decimalFormatter.format(quote?.output ?? 0);\n\n  const actionLabel =\n    status === \"submitting\"\n      ? \"Swapping…\"\n      : status === \"error\"\n        ? \"Try again\"\n        : \"Review swap\";\n\n  const handleFlip = () => {\n    setFlipped((prev) => !prev);\n    onFlip();\n  };\n\n  const rows: { side: \"from\" | \"to\"; token: SwapToken }[] = [\n    { side: \"from\", token: from },\n    { side: \"to\", token: to },\n  ];\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-sm rounded-2xl border bg-background p-4\",\n        className\n      )}\n    >\n      <div className=\"flex flex-col gap-2\">\n        {rows.map((row) => (\n          <motion.div\n            className=\"relative\"\n            key={row.token.symbol}\n            layout={!shouldReduceMotion}\n            transition={\n              shouldReduceMotion ? { duration: 0 } : SPRING_TRANSITION\n            }\n          >\n            {row.side === \"from\" ? (\n              <div>\n                <label\n                  className=\"mb-1 block px-1 text-muted-foreground text-xs\"\n                  htmlFor={fromInputId}\n                >\n                  You pay\n                </label>\n                <div className=\"flex items-center justify-between gap-2 rounded-xl border bg-secondary/40 px-3 py-2\">\n                  <input\n                    className=\"w-full min-w-0 bg-transparent font-semibold text-2xl outline-none placeholder:text-muted-foreground/40\"\n                    id={fromInputId}\n                    inputMode=\"decimal\"\n                    onChange={(event) => onAmountChange(event.target.value)}\n                    placeholder=\"0.0\"\n                    type=\"text\"\n                    value={amount}\n                  />\n                  <TokenChip\n                    onSelect={() => onSelectToken?.(\"from\")}\n                    side=\"from\"\n                    token={from}\n                  />\n                </div>\n                {typeof from.balance === \"number\" ? (\n                  <div className=\"mt-1 flex items-center gap-2 px-1 text-muted-foreground text-xs\">\n                    <span>Balance {decimalFormatter.format(from.balance)}</span>\n                    <button\n                      className=\"rounded font-medium text-foreground underline decoration-foreground/30 underline-offset-2 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n                      onClick={() => onAmountChange(String(from.balance))}\n                      type=\"button\"\n                    >\n                      Max\n                    </button>\n                  </div>\n                ) : null}\n              </div>\n            ) : (\n              <div>\n                <span\n                  className=\"mb-1 block px-1 text-muted-foreground text-xs\"\n                  id={toLabelId}\n                >\n                  You receive\n                </span>\n                <div className=\"relative flex items-center justify-between gap-2 overflow-hidden rounded-xl border bg-secondary/40 px-3 py-2\">\n                  {status === \"quoting\" &&\n                    (shouldReduceMotion ? (\n                      <div\n                        aria-hidden=\"true\"\n                        className=\"absolute inset-0 bg-muted-foreground/10\"\n                      />\n                    ) : (\n                      <div\n                        aria-hidden=\"true\"\n                        className=\"pointer-events-none absolute inset-0 overflow-hidden\"\n                      >\n                        <motion.div\n                          animate={{ x: [\"-100%\", \"220%\"] }}\n                          className=\"h-full w-1/3 bg-gradient-to-r from-transparent via-foreground/10 to-transparent\"\n                          transition={{\n                            duration: SHIMMER_DURATION,\n                            ease: SHIMMER_EASE,\n                            repeat: Number.POSITIVE_INFINITY,\n                          }}\n                        />\n                      </div>\n                    ))}\n                  <div\n                    aria-labelledby={toLabelId}\n                    aria-live=\"polite\"\n                    className=\"relative min-h-8 flex-1 font-semibold text-2xl\"\n                  >\n                    <RollingFigure\n                      shouldReduceMotion={shouldReduceMotion}\n                      value={outputFormatted}\n                    />\n                  </div>\n                  <TokenChip\n                    onSelect={() => onSelectToken?.(\"to\")}\n                    side=\"to\"\n                    token={to}\n                  />\n                </div>\n              </div>\n            )}\n          </motion.div>\n        ))}\n\n        <div className=\"flex justify-center\">\n          <SmoothButton\n            aria-label=\"Flip pay and receive tokens\"\n            className=\"z-10 -my-4\"\n            onClick={handleFlip}\n            shape=\"pill\"\n            size=\"icon-sm\"\n            variant=\"outline\"\n          >\n            {/* The rotation rides the icon, not the button: the frame is a\n                circle, so spinning it only ever moved its shadow. */}\n            <motion.span\n              animate={{ rotate: flipped ? 180 : 0 }}\n              className=\"flex\"\n              transition={\n                shouldReduceMotion ? { duration: 0 } : SPRING_TRANSITION\n              }\n            >\n              <ArrowLeftRight aria-hidden=\"true\" />\n            </motion.span>\n          </SmoothButton>\n        </div>\n      </div>\n\n      {quote ? (\n        <div className=\"mt-3 flex flex-wrap items-center gap-2 px-1 text-muted-foreground text-xs\">\n          {quote.expiresAt ? (\n            <CountdownRing\n              expiresAt={quote.expiresAt}\n              shouldReduceMotion={shouldReduceMotion}\n            />\n          ) : null}\n          <span>\n            1 {from.symbol} = {decimalFormatter.format(quote.rate)} {to.symbol}\n          </span>\n          {typeof quote.fee === \"number\" ? (\n            <span>\n              Fee {decimalFormatter.format(quote.fee)} {to.symbol}\n            </span>\n          ) : null}\n          {typeof quote.priceImpact === \"number\" ? (\n            <span>Impact {percentFormatter.format(quote.priceImpact)}</span>\n          ) : null}\n          {typeof slippage === \"number\" ? (\n            <span>Max slippage {percentFormatter.format(slippage)}</span>\n          ) : null}\n        </div>\n      ) : null}\n\n      {error ? (\n        <p className=\"mt-2 px-1 text-destructive text-xs\" role=\"alert\">\n          {error}\n        </p>\n      ) : null}\n\n      <SmoothButton\n        className=\"mt-3 w-full rounded-xl font-semibold\"\n        disabled={isBusy}\n        loading={status === \"submitting\"}\n        onClick={onSubmit}\n        // `candy` is the house primary, matching the CTA and footer blocks.\n        variant=\"candy\"\n      >\n        {actionLabel}\n      </SmoothButton>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/swap-panel/index.tsx","type":"registry:ui"}],"name":"swap-panel","registryDependencies":["https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/tokens.json"],"title":"Swap Panel","type":"registry:ui"}