Smooth Button
Smooth Button is a React and Tailwind CSS button component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
buttons
What it is
Smooth Button is a React and Tailwind CSS button component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i @radix-ui/react-slot class-variance-authority motion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Smooth Button
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ButtonHTMLAttributes, ReactNode, Ref } from "react";
import { useEffect, useRef } from "react";
/**
* SmoothButton — the first primitive of the SmoothUI Design System.
*
* Three orthogonal axes (see design-system/components/button.md):
* variant → appearance only (solid | soft | outline | ghost | link | candy)
* color → hue (accent | neutral | destructive | blue | amber | green)
* size → xs | sm | default | lg + icon-*
*
* The `color` axis only sets CSS custom props (--btn, --btn-hover, --btn-fg);
* each `variant` consumes them generically, so 6 variants × 6 colors stay 12
* class strings, not 36. When no `color` is given, CSS var fallbacks apply
* (candy → brand, everything else → neutral/foreground).
*
* Legacy variants `default` / `secondary` / `destructive` are preserved verbatim
* for back-compat with existing call sites and ignore the `color` axis.
*/
const smoothButtonVariants = cva(
"relative inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap font-medium outline-none ring-offset-background transition-[transform,background-color,border-color,color,box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
defaultVariants: {
shape: "default",
size: "default",
variant: "default",
},
variants: {
color: {
accent:
"[--btn-fg:#fff] [--btn-hover:var(--color-brand-secondary)] [--btn:var(--color-brand)]",
amber:
"[--btn-fg:var(--color-amber-fg)] [--btn-hover:var(--color-amber-hover)] [--btn:var(--color-amber)]",
blue: "[--btn-fg:var(--color-blue-fg)] [--btn-hover:var(--color-blue-hover)] [--btn:var(--color-blue)]",
destructive:
"[--btn-fg:#fff] [--btn-hover:color-mix(in_oklab,var(--color-destructive)_85%,black)] [--btn:var(--color-destructive)]",
green:
"[--btn-fg:var(--color-green-fg)] [--btn-hover:var(--color-green-hover)] [--btn:var(--color-green)]",
neutral:
"[--btn-fg:var(--color-background)] [--btn-hover:var(--color-smooth-900)] [--btn:var(--color-foreground)]",
},
shape: {
default: "",
pill: "rounded-full!",
square: "rounded-none!",
},
size: {
default: "h-10 gap-2 rounded-md px-4 py-2 text-sm [&_svg]:size-4",
icon: "size-10 rounded-md [&_svg]:size-4",
"icon-lg": "size-11 rounded-lg [&_svg]:size-5",
"icon-sm": "size-9 rounded-md [&_svg]:size-4",
lg: "h-11 gap-2 rounded-lg px-8 text-base [&_svg]:size-5",
sm: "h-9 gap-1.5 rounded-md px-3 text-sm [&_svg]:size-4",
xs: "h-7 gap-1.5 rounded-sm px-2.5 text-xs [&_svg]:size-3.5",
},
variant: {
candy:
"border-[0.5px] border-white/25 bg-gradient-to-b from-[var(--btn,var(--color-brand))] to-[var(--btn-hover,var(--color-brand-secondary))] text-[var(--btn-fg,#fff)] text-shadow-sm shadow-black/20 shadow-md ring-1 ring-[color-mix(in_oklab,var(--color-foreground)_15%,var(--btn,var(--color-brand)))] hover:from-[var(--btn-hover,var(--color-brand-secondary))] hover:to-[var(--btn-hover,var(--color-brand-secondary))] [&_svg]:drop-shadow-sm",
// --- legacy (preserved verbatim, ignore `color`) ---
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-gradient-to-b from-[#FD4B4E] to-destructive text-shadow-sm text-white shadow-[0px_1px_2px_rgba(0,0,0,0.4),0px_0px_0px_1px_#F61418,inset_0px_0.75px_0px_rgba(255,255,255,0.2)] hover:from-destructive hover:to-destructive",
ghost:
"text-[var(--btn,var(--color-foreground))] hover:bg-[color-mix(in_oklab,var(--btn,var(--color-foreground))_10%,transparent)]",
link: "text-[var(--btn,var(--color-foreground))] underline-offset-4 hover:underline",
outline:
"border border-transparent bg-background text-[var(--btn,var(--color-foreground))] shadow-black/15 shadow-sm ring-1 ring-foreground/10 hover:bg-primary dark:ring-foreground/15",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
soft: "bg-[color-mix(in_oklab,var(--btn,var(--color-foreground))_12%,transparent)] text-[var(--btn,var(--color-foreground))] hover:bg-[color-mix(in_oklab,var(--btn,var(--color-foreground))_18%,transparent)]",
// --- new decoupled system (consume --btn / --btn-hover / --btn-fg) ---
solid:
"bg-[var(--btn,var(--color-foreground))] text-[var(--btn-fg,var(--color-background))] shadow-xs hover:bg-[var(--btn-hover,var(--color-smooth-900))]",
},
},
}
);
export type SmoothButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"prefix" | "color"
> &
VariantProps<typeof smoothButtonVariants> & {
asChild?: boolean;
/** Show a spinner that morphs the button width without layout jump. */
loading?: boolean;
/** Content before the label (icon, Kbd…). */
prefix?: ReactNode;
/** Content after the label. */
suffix?: ReactNode;
/** Opt-in Safari force-press depth (scales to 0.94 under pressure). */
forcePress?: boolean;
ref?: Ref<HTMLButtonElement>;
};
const Spinner = () => (
<svg
aria-hidden="true"
className="size-[1em] animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-90"
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3"
/>
</svg>
);
function SmoothButton({
className,
variant,
color,
size,
shape,
asChild = false,
loading = false,
forcePress = false,
prefix,
suffix,
disabled,
children,
ref,
...props
}: SmoothButtonProps) {
const shouldReduceMotion = useReducedMotion();
const localRef = useRef<HTMLButtonElement>(null);
// Safari-only force-press: deepen the press past the normal active scale.
useEffect(() => {
const node = localRef.current;
if (!(forcePress && node) || shouldReduceMotion) {
return;
}
const FORCE_THRESHOLD = 2; // Safari's "force click" boundary
const onForce = (e: Event) => {
const force = (e as Event & { webkitForce?: number }).webkitForce ?? 0;
node.style.transform = force >= FORCE_THRESHOLD ? "scale(0.94)" : "";
};
const reset = () => {
node.style.transform = "";
};
node.addEventListener("webkitmouseforcechanged", onForce);
node.addEventListener("mouseup", reset);
node.addEventListener("mouseleave", reset);
return () => {
node.removeEventListener("webkitmouseforcechanged", onForce);
node.removeEventListener("mouseup", reset);
node.removeEventListener("mouseleave", reset);
};
}, [forcePress, shouldReduceMotion]);
const classes = cn(
smoothButtonVariants({ className, color, shape, size, variant })
);
// asChild defers all rendering to the consumer's element — slots/loading
// are not injected (single-child contract of Radix Slot).
if (asChild) {
return (
<Slot className={classes} ref={ref} {...props}>
{children}
</Slot>
);
}
const setRefs = (node: HTMLButtonElement | null) => {
localRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as { current: HTMLButtonElement | null }).current = node;
}
};
return (
<button
aria-busy={loading || undefined}
className={classes}
disabled={disabled || loading}
ref={setRefs}
type={props.type ?? "button"}
{...props}
>
<AnimatePresence initial={false}>
{loading ? (
<motion.span
animate={{ marginRight: "0.5rem", opacity: 1, width: "1em" }}
className="inline-flex shrink-0 items-center justify-center overflow-hidden"
exit={{ marginRight: 0, opacity: 0, width: 0 }}
initial={
shouldReduceMotion
? { marginRight: "0.5rem", opacity: 1, width: "1em" }
: { marginRight: 0, opacity: 0, width: 0 }
}
key="spinner"
transition={
shouldReduceMotion
? { duration: 0 }
: { bounce: 0.1, duration: 0.25, type: "spring" }
}
>
<Spinner />
</motion.span>
) : null}
</AnimatePresence>
{prefix}
{children}
{suffix}
</button>
);
}
export default SmoothButton;
export { smoothButtonVariants };