Soft Set Button
Original · freeSoft, tinted buttons across semantic colours.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/btn-soft-set.json"use client";
import {
useState,
useRef,
type ReactNode,
type ButtonHTMLAttributes,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type Tone =
| "slate"
| "indigo"
| "violet"
| "emerald"
| "sky"
| "amber"
| "rose";
type Size = "sm" | "md" | "lg";
const TONE_CLASSES: Record<Tone, string> = {
slate:
"bg-slate-100 text-slate-700 hover:bg-slate-200 focus-visible:ring-slate-400 dark:bg-slate-800/70 dark:text-slate-200 dark:hover:bg-slate-800",
indigo:
"bg-indigo-50 text-indigo-700 hover:bg-indigo-100 focus-visible:ring-indigo-400 dark:bg-indigo-950/50 dark:text-indigo-300 dark:hover:bg-indigo-900/60",
violet:
"bg-violet-50 text-violet-700 hover:bg-violet-100 focus-visible:ring-violet-400 dark:bg-violet-950/50 dark:text-violet-300 dark:hover:bg-violet-900/60",
emerald:
"bg-emerald-50 text-emerald-700 hover:bg-emerald-100 focus-visible:ring-emerald-400 dark:bg-emerald-950/50 dark:text-emerald-300 dark:hover:bg-emerald-900/60",
sky:
"bg-sky-50 text-sky-700 hover:bg-sky-100 focus-visible:ring-sky-400 dark:bg-sky-950/50 dark:text-sky-300 dark:hover:bg-sky-900/60",
amber:
"bg-amber-50 text-amber-700 hover:bg-amber-100 focus-visible:ring-amber-400 dark:bg-amber-950/50 dark:text-amber-300 dark:hover:bg-amber-900/60",
rose:
"bg-rose-50 text-rose-700 hover:bg-rose-100 focus-visible:ring-rose-400 dark:bg-rose-950/50 dark:text-rose-300 dark:hover:bg-rose-900/60",
};
const SIZE_CLASSES: Record<Size, string> = {
sm: "h-8 gap-1.5 rounded-lg px-3 text-xs",
md: "h-10 gap-2 rounded-xl px-4 text-sm",
lg: "h-12 gap-2.5 rounded-xl px-5 text-[0.95rem]",
};
const ICON_SIZE_CLASSES: Record<Size, string> = {
sm: "h-8 w-8 rounded-lg",
md: "h-10 w-10 rounded-xl",
lg: "h-12 w-12 rounded-xl",
};
const BASE =
"bsset inline-flex select-none items-center justify-center font-medium tracking-tight " +
"transition-[background-color,transform,box-shadow] duration-150 active:scale-[0.97] " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 " +
"focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 " +
"disabled:pointer-events-none disabled:opacity-50";
interface SoftButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
tone?: Tone;
size?: Size;
loading?: boolean;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
}
function SoftButton({
tone = "indigo",
size = "md",
loading = false,
leadingIcon,
trailingIcon,
children,
className = "",
disabled,
...rest
}: SoftButtonProps) {
return (
<button
className={`${BASE} ${SIZE_CLASSES[size]} ${TONE_CLASSES[tone]} ${className}`}
disabled={disabled ?? loading}
aria-busy={loading || undefined}
{...rest}
>
{loading ? (
<span className="bsset-spin" aria-hidden="true">
<Spinner />
</span>
) : (
leadingIcon
)}
<span className={loading ? "opacity-90" : undefined}>{children}</span>
{!loading && trailingIcon}
</button>
);
}
/* ---------- inline icons ---------- */
function Spinner() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth="2.5"
className="opacity-25"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
);
}
function IconCheck() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="m5 13 4 4L19 7"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconCopy() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<rect
x="9"
y="9"
width="11"
height="11"
rx="2.5"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M5 15V6a2.5 2.5 0 0 1 2.5-2.5H15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function IconHeart({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill={filled ? "currentColor" : "none"}
aria-hidden="true"
>
<path
d="M12 20.5S3.5 15 3.5 8.9A4.4 4.4 0 0 1 12 6.9a4.4 4.4 0 0 1 8.5 2c0 6.1-8.5 11.6-8.5 11.6Z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
);
}
function IconBell({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill={filled ? "currentColor" : "none"}
aria-hidden="true"
>
<path
d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6Z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<path
d="M10 19a2 2 0 0 0 4 0"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function IconArrow() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M5 12h14m0 0-5-5m5 5-5 5"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconDownload() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M12 4v10m0 0 4-4m-4 4-4-4M5 19h14"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconTrash() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7m-7 0 .8 11a2 2 0 0 0 2 1.9h4.4a2 2 0 0 0 2-1.9L17 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconSparkle() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M12 4c.4 3.4 1.2 4.2 4.6 4.6-3.4.4-4.2 1.2-4.6 4.6-.4-3.4-1.2-4.2-4.6-4.6C11.8 8.2 12.6 7.4 12 4Z"
fill="currentColor"
/>
<path
d="M18 14c.2 1.7.6 2.1 2.3 2.3-1.7.2-2.1.6-2.3 2.3-.2-1.7-.6-2.1-2.3-2.3 1.7-.2 2.1-.6 2.3-2.3Z"
fill="currentColor"
/>
</svg>
);
}
function IconPlus() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
);
}
/* ---------- interactive demos ---------- */
function LikeButton() {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(248);
const reduce = useReducedMotion();
return (
<SoftButton
tone="rose"
aria-pressed={liked}
onClick={() => {
setLiked((v) => !v);
setCount((c) => (liked ? c - 1 : c + 1));
}}
leadingIcon={
<motion.span
key={liked ? "on" : "off"}
initial={reduce ? false : { scale: 0.6 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 18 }}
className="inline-flex"
>
<IconHeart filled={liked} />
</motion.span>
}
>
{liked ? "Liked" : "Like"}
<span className="tabular-nums opacity-70">· {count}</span>
</SoftButton>
);
}
function CopyButton() {
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
const reduce = useReducedMotion();
const value = "npm i motion";
const onCopy = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
/* clipboard unavailable */
}
setCopied(true);
if (timer.current) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1800);
};
return (
<SoftButton
tone="slate"
onClick={onCopy}
aria-label={copied ? "Copied to clipboard" : `Copy ${value}`}
leadingIcon={copied ? <IconCheck /> : <IconCopy />}
>
<span className="font-mono text-[0.8em]">{value}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? "done" : "idle"}
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -4 }}
transition={{ duration: 0.16 }}
className="ml-1 text-[0.72rem] font-semibold uppercase tracking-wide opacity-70"
>
{copied ? "Copied" : "Copy"}
</motion.span>
</AnimatePresence>
</SoftButton>
);
}
function FollowButton() {
const [on, setOn] = useState(false);
return (
<SoftButton
tone="sky"
aria-pressed={on}
onClick={() => setOn((v) => !v)}
leadingIcon={<IconBell filled={on} />}
>
{on ? "Following" : "Follow"}
</SoftButton>
);
}
function SaveButton() {
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
const timer = useRef<number | null>(null);
const run = () => {
if (loading || saved) return;
setLoading(true);
if (timer.current) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => {
setLoading(false);
setSaved(true);
}, 1400);
};
return (
<SoftButton
tone="emerald"
loading={loading}
onClick={run}
leadingIcon={saved ? <IconCheck /> : undefined}
>
{saved ? "Saved" : loading ? "Saving…" : "Save changes"}
</SoftButton>
);
}
/* ---------- showcase ---------- */
const TONES: readonly Tone[] = [
"slate",
"indigo",
"violet",
"sky",
"emerald",
"amber",
"rose",
];
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{label}
</span>
<div className="flex flex-wrap items-center gap-3">{children}</div>
</div>
);
}
export default function BtnSoftSet() {
return (
<section className="relative w-full bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:px-10">
<style>{`
@keyframes bsset-spin { to { transform: rotate(360deg); } }
.bsset-spin { display:inline-flex; animation: bsset-spin 0.7s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.bsset-spin { animation: none; }
.bsset { transition: none !important; }
}
`}</style>
<div className="mx-auto flex max-w-3xl flex-col gap-12">
<header className="flex flex-col gap-2">
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-300">
<IconSparkle />
Soft button set
</span>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
Tinted, tactile buttons across semantic colours
</h2>
<p className="max-w-prose text-sm text-slate-500 dark:text-slate-400">
Low-contrast fills that stay legible in light and dark, with real
focus rings, loading and disabled states, and toggles that actually
hold state.
</p>
</header>
<div className="flex flex-col gap-10 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-8">
<Row label="Semantic tones">
{TONES.map((tone) => (
<SoftButton key={tone} tone={tone} leadingIcon={<IconSparkle />}>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</SoftButton>
))}
</Row>
<Row label="Sizes">
<SoftButton tone="violet" size="sm" trailingIcon={<IconArrow />}>
Small
</SoftButton>
<SoftButton tone="violet" size="md" trailingIcon={<IconArrow />}>
Medium
</SoftButton>
<SoftButton tone="violet" size="lg" trailingIcon={<IconArrow />}>
Large
</SoftButton>
</Row>
<Row label="With icons">
<SoftButton tone="indigo" leadingIcon={<IconDownload />}>
Download report
</SoftButton>
<SoftButton tone="emerald" trailingIcon={<IconArrow />}>
Continue
</SoftButton>
<SoftButton tone="rose" leadingIcon={<IconTrash />}>
Delete draft
</SoftButton>
</Row>
<Row label="Icon only">
<button
className={`${BASE} ${ICON_SIZE_CLASSES.md} ${TONE_CLASSES.sky}`}
aria-label="Add item"
>
<IconPlus />
</button>
<button
className={`${BASE} ${ICON_SIZE_CLASSES.md} ${TONE_CLASSES.amber}`}
aria-label="Download file"
>
<IconDownload />
</button>
<button
className={`${BASE} ${ICON_SIZE_CLASSES.md} ${TONE_CLASSES.slate}`}
aria-label="Copy link"
>
<IconCopy />
</button>
</Row>
<Row label="States">
<SoftButton tone="indigo">Enabled</SoftButton>
<SoftButton tone="indigo" disabled>
Disabled
</SoftButton>
<SoftButton tone="indigo" loading>
Processing
</SoftButton>
</Row>
<Row label="Live interactions">
<LikeButton />
<FollowButton />
<CopyButton />
<SaveButton />
</Row>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

Pill Group Button
OriginalA segmented pill button group with a sliding active indicator.

