Skip to content

Slippage Settings

Preset chips with a sliding indicator, custom input and warnings.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/slippage-settings.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --warning, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --warning: #b45309;
    }
    
    .dark {
      --warning: #fbbf24;
    }
    
    @theme inline {
      --color-warning: var(--warning);
    }
  4. Copy the source

    components/ui/slippage-settings/slippage-settings.tsx
    "use client";
    
    import { useId, useState, type ReactNode } from "react";
    import { Popover } from "radix-ui";
    import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { AlertTriangle, Settings2 } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { overlayVariants, spring } from "@/lib/motion";
    
    export interface SlippageSettingsProps {
      /** Max slippage in basis points (50 = 0.5%). */
      value: number;
      /** Called with the new slippage in basis points. */
      onValueChange: (bps: number) => void;
      /** Preset chips in basis points. @default [10, 50, 100] */
      presets?: number[];
      /** Custom trigger. Defaults to a settings button showing the current value. */
      trigger?: ReactNode;
      /** Popover alignment against the trigger. @default "end" */
      align?: "start" | "center" | "end";
      /** Below this many bps, warn that the transaction may fail. @default 5 */
      lowThreshold?: number;
      /** Above this many bps, warn about frontrunning. @default 500 */
      highThreshold?: number;
      /** Highest accepted value in bps. @default 5000 */
      max?: number;
      /** Controlled open state. */
      open?: boolean;
      /** Called when the popover opens or closes. */
      onOpenChange?: (open: boolean) => void;
    }
    
    /** "0.5" → 50 bps, using string math only. Returns null for anything that isn't a percentage. */
    function percentToBps(text: string): number | null {
      const clean = text.replace(",", ".").replace(/%$/, "").trim();
      if (!/^\d*\.?\d{0,2}$/.test(clean) || clean === "" || clean === ".") return null;
      const [integer, fraction = ""] = clean.split(".");
      return Number(integer || "0") * 100 + Number(fraction.padEnd(2, "0"));
    }
    
    const bpsToPercent = (bps: number) => `${Math.floor(bps / 100)}${bps % 100 ? `.${String(bps % 100).padStart(2, "0").replace(/0$/, "")}` : ""}`;
    
    export function SlippageSettings({
      value,
      onValueChange,
      presets = [10, 50, 100],
      trigger,
      align = "end",
      lowThreshold = 5,
      highThreshold = 500,
      max = 5000,
      open,
      onOpenChange,
    }: SlippageSettingsProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const id = useId();
      const isPreset = presets.includes(value);
      const [draft, setDraft] = useState<string | null>(null);
      const shown = draft ?? (isPreset ? "" : bpsToPercent(value));
      const draftBps = draft === null ? null : percentToBps(draft);
      const invalid = draft !== null && draft !== "" && (draftBps === null || draftBps > max);
    
      const warning = invalid
        ? { tone: "error" as const, text: `Enter a value between 0.01% and ${bpsToPercent(max)}%.` }
        : value < lowThreshold
          ? { tone: "warning" as const, text: "Slippage this low may make the transaction fail." }
          : value > highThreshold
            ? { tone: "warning" as const, text: "High slippage: the trade can be frontrun and fill at a worse price." }
            : null;
    
      function commit(text: string) {
        const bps = percentToBps(text);
        if (bps !== null && bps > 0 && bps <= max) onValueChange(bps);
        setDraft(null);
      }
    
      return (
        <Popover.Root open={open} onOpenChange={onOpenChange}>
          <Popover.Trigger asChild>
            {trigger ?? (
              <button
                type="button"
                className="relative after:absolute after:inset-x-0 after:-inset-y-0.5 inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-foreground"
              >
                <Settings2 className="size-4" aria-hidden="true" />
                <span>
                  <span className="sr-only">Slippage settings, </span>
                  <span className="font-mono tabular-nums">{bpsToPercent(value)}%</span>
                </span>
              </button>
            )}
          </Popover.Trigger>
          <Popover.Portal>
            <Popover.Content align={align} sideOffset={8} collisionPadding={12} asChild>
              <motion.div
                variants={overlayVariants(reduceMotion)}
                initial="hidden"
                animate="visible"
                className="z-50 w-72 origin-[var(--radix-popover-content-transform-origin)] rounded-2xl border border-border bg-popover p-4 text-popover-foreground shadow-lg outline-none"
              >
                <p id={`${id}-label`} className="text-sm font-semibold text-foreground">
                  Max slippage
                </p>
                <p className="mt-0.5 text-xs leading-5 text-muted-foreground">Your swap reverts if the price moves more than this.</p>
                <LayoutGroup id={id}>
                  <div className="mt-3 flex gap-1 rounded-xl bg-muted p-1">
                    <div role="radiogroup" aria-labelledby={`${id}-label`} className="flex flex-[3] gap-1">
                    {presets.map((bps) => {
                      const active = bps === value && draft === null;
                      return (
                        <button
                          key={bps}
                          type="button"
                          role="radio"
                          aria-checked={active}
                          onClick={() => {
                            setDraft(null);
                            onValueChange(bps);
                          }}
                          className={cn(
                            "relative after:absolute after:inset-x-0 after:-inset-y-0.5 h-9 flex-1 cursor-pointer rounded-lg font-mono text-[13px] font-medium tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring",
                            active ? "text-foreground" : "text-foreground/65 hover:text-foreground",
                          )}
                        >
                          {active ? (
                            <motion.span
                              layoutId="slippage-indicator"
                              transition={reduceMotion ? { duration: 0 } : spring.snappy}
                              className="absolute inset-0 rounded-lg bg-background shadow-xs"
                            />
                          ) : null}
                          <span className="relative">{bpsToPercent(bps)}%</span>
                        </button>
                      );
                    })}
                    </div>
                    <label className="relative flex h-9 flex-[1.3] items-center rounded-lg">
                      {!isPreset && draft === null ? (
                        <motion.span
                          layoutId="slippage-indicator"
                          transition={reduceMotion ? { duration: 0 } : spring.snappy}
                          className="absolute inset-0 rounded-lg bg-background shadow-xs"
                        />
                      ) : null}
                      <span className="sr-only">Custom slippage percentage</span>
                      <input
                        value={shown}
                        onChange={(event) => setDraft(event.target.value)}
                        onBlur={(event) => commit(event.target.value)}
                        onKeyDown={(event) => {
                          if (event.key === "Enter") commit(event.currentTarget.value);
                        }}
                        inputMode="decimal"
                        placeholder="Custom"
                        aria-invalid={invalid || undefined}
                        className={cn(
                          "relative h-full w-full min-w-0 rounded-lg bg-transparent pr-6 pl-2.5 text-right font-mono text-[13px] font-medium tabular-nums outline-none placeholder:font-sans placeholder:text-foreground/50 focus:bg-background focus:ring-2 focus:ring-ring",
                          invalid ? "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" : "text-foreground",
                        )}
                      />
                      <span className="pointer-events-none absolute right-2.5 text-[13px] text-muted-foreground" aria-hidden="true">
                        %
                      </span>
                    </label>
                  </div>
                </LayoutGroup>
                <AnimatePresence initial={false}>
                  {warning ? (
                    <motion.div
                      key={warning.text}
                      role="alert"
                      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(3px)" }}
                      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: 0.2 }}
                      className={cn(
                        "mt-3 flex gap-2 rounded-lg p-2.5 text-xs leading-5 text-foreground",
                        warning.tone === "error" ? "bg-destructive/8" : "bg-warning/10",
                      )}
                    >
                      <AlertTriangle
                        className={cn(
                          "mt-0.5 size-3.5 shrink-0",
                          warning.tone === "error"
                            ? "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]"
                            : "text-[color-mix(in_oklab,var(--warning)_55%,var(--foreground))]",
                        )}
                        aria-hidden="true"
                      />
                      {warning.text}
                    </motion.div>
                  ) : null}
                </AnimatePresence>
              </motion.div>
            </Popover.Content>
          </Popover.Portal>
        </Popover.Root>
      );
    }

Usage

import { SlippageSettings } from "@/components/ui/slippage-settings/slippage-settings";

const [slippage, setSlippage] = useState(50); // basis points: 50 = 0.5%

<SlippageSettings value={slippage} onValueChange={setSlippage} />

API reference

PropTypeDefaultDescription
value*number–Max slippage in basis points (50 = 0.5%).
onValueChange*(bps: number) => void–Called with the new slippage in basis points.
presetsnumber[][10, 50, 100]Preset chips in basis points.
triggerReactNode–Custom trigger. Defaults to a settings button showing the current value.
align"start" | "center" | "end""end"Popover alignment against the trigger.
lowThresholdnumber5Below this many bps, warn that the transaction may fail.
highThresholdnumber500Above this many bps, warn about frontrunning.
maxnumber5000Highest accepted value in bps.
openboolean–Controlled open state.
onOpenChange(open: boolean) => void–Called when the popover opens or closes.
  • Values are basis points. Custom input is parsed with string math (no floats), accepts up to two decimals and commits on Enter or blur.
  • Tune lowThreshold, highThreshold and max to match your protocol's limits.

Accessibility

  • Presets are a labelled radio group; the custom field has its own label and sets aria-invalid on bad input.
  • Warnings (too low, frontrun risk, invalid) are alerts with an icon and text, never color alone.
  • The trigger names itself "Slippage settings, 0.5%". The popover is a Radix Popover with focus management and Escape to close.
  • The active pill slides between presets and the custom field. Reduced motion moves it instantly.