Web InnoventixFreeCode

Soft Set Button

Original · free

Soft, tinted buttons across semantic colours.

byWeb InnoventixReact + Tailwind
btnsoftsetbuttons
Open

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
btn-soft-set.tsx
"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

motion

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 quote

Similar components

Browse all →