Web3: DeFi & Tradingv0.2.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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); }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
| Prop | Type | Default | Description |
|---|---|---|---|
value* | number | – | Max slippage in basis points (50 = 0.5%). |
onValueChange* | (bps: number) => void | – | Called with the new slippage in basis points. |
presets | number[] | [10, 50, 100] | Preset chips in basis points. |
trigger | ReactNode | – | Custom trigger. Defaults to a settings button showing the current value. |
align | "start" | "center" | "end" | "end" | Popover alignment against the trigger. |
lowThreshold | number | 5 | Below this many bps, warn that the transaction may fail. |
highThreshold | number | 500 | Above this many bps, warn about frontrunning. |
max | number | 5000 | Highest accepted value in bps. |
open | boolean | – | 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.