Web InnoventixFreeCode

Ghost Set Button

Original · free

Ghost and text buttons with subtle hover backgrounds.

byWeb InnoventixReact + Tailwind
btnghostsetbuttons
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-ghost-set.json
btn-ghost-set.tsx
"use client";

import { useState, type ReactNode } from "react";

type Tone = "slate" | "indigo" | "violet" | "emerald" | "rose" | "sky";
type Size = "sm" | "md" | "lg";

const toneMap: Record<
  Tone,
  { text: string; hover: string; active: string; ring: string }
> = {
  slate: {
    text: "text-slate-700 dark:text-slate-200",
    hover:
      "hover:bg-slate-900/[0.06] dark:hover:bg-white/[0.08]",
    active: "active:bg-slate-900/[0.10] dark:active:bg-white/[0.12]",
    ring: "focus-visible:ring-slate-400 dark:focus-visible:ring-slate-500",
  },
  indigo: {
    text: "text-indigo-600 dark:text-indigo-300",
    hover: "hover:bg-indigo-500/[0.10] dark:hover:bg-indigo-400/[0.14]",
    active: "active:bg-indigo-500/[0.16] dark:active:bg-indigo-400/[0.20]",
    ring: "focus-visible:ring-indigo-400 dark:focus-visible:ring-indigo-500",
  },
  violet: {
    text: "text-violet-600 dark:text-violet-300",
    hover: "hover:bg-violet-500/[0.10] dark:hover:bg-violet-400/[0.14]",
    active: "active:bg-violet-500/[0.16] dark:active:bg-violet-400/[0.20]",
    ring: "focus-visible:ring-violet-400 dark:focus-visible:ring-violet-500",
  },
  emerald: {
    text: "text-emerald-600 dark:text-emerald-300",
    hover: "hover:bg-emerald-500/[0.10] dark:hover:bg-emerald-400/[0.14]",
    active: "active:bg-emerald-500/[0.16] dark:active:bg-emerald-400/[0.20]",
    ring: "focus-visible:ring-emerald-400 dark:focus-visible:ring-emerald-500",
  },
  rose: {
    text: "text-rose-600 dark:text-rose-300",
    hover: "hover:bg-rose-500/[0.10] dark:hover:bg-rose-400/[0.14]",
    active: "active:bg-rose-500/[0.16] dark:active:bg-rose-400/[0.20]",
    ring: "focus-visible:ring-rose-400 dark:focus-visible:ring-rose-500",
  },
  sky: {
    text: "text-sky-600 dark:text-sky-300",
    hover: "hover:bg-sky-500/[0.10] dark:hover:bg-sky-400/[0.14]",
    active: "active:bg-sky-500/[0.16] dark:active:bg-sky-400/[0.20]",
    ring: "focus-visible:ring-sky-400 dark:focus-visible:ring-sky-500",
  },
};

const sizeMap: Record<Size, { pad: string; text: string; gap: string; icon: string }> = {
  sm: { pad: "px-2.5 py-1.5", text: "text-[13px]", gap: "gap-1.5", icon: "h-3.5 w-3.5" },
  md: { pad: "px-3.5 py-2", text: "text-sm", gap: "gap-2", icon: "h-4 w-4" },
  lg: { pad: "px-5 py-2.5", text: "text-[15px]", gap: "gap-2.5", icon: "h-[18px] w-[18px]" },
};

const base =
  "bgs-btn relative inline-flex select-none items-center justify-center rounded-lg font-medium " +
  "outline-none transition-colors duration-150 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-45";

function GhostButton({
  children,
  tone = "slate",
  size = "md",
  leading,
  trailing,
  disabled,
  loading,
  underline,
  "aria-label": ariaLabel,
  onClick,
}: {
  children?: ReactNode;
  tone?: Tone;
  size?: Size;
  leading?: ReactNode;
  trailing?: ReactNode;
  disabled?: boolean;
  loading?: boolean;
  underline?: boolean;
  "aria-label"?: string;
  onClick?: () => void;
}) {
  const t = toneMap[tone];
  const s = sizeMap[size];
  return (
    <button
      type="button"
      aria-label={ariaLabel}
      aria-busy={loading || undefined}
      disabled={disabled || loading}
      onClick={onClick}
      className={[
        base,
        s.pad,
        s.text,
        s.gap,
        t.text,
        t.hover,
        t.active,
        t.ring,
        underline
          ? "underline decoration-current/30 decoration-1 underline-offset-4 hover:decoration-current/60"
          : "",
      ].join(" ")}
    >
      {loading ? (
        <span
          className={["bgs-spin shrink-0 rounded-full border-2 border-current border-r-transparent", s.icon].join(" ")}
          aria-hidden="true"
        />
      ) : (
        leading && <span className={["shrink-0", s.icon].join(" ")} aria-hidden="true">{leading}</span>
      )}
      {children && <span className="truncate">{children}</span>}
      {!loading && trailing && (
        <span className={["shrink-0", s.icon].join(" ")} aria-hidden="true">{trailing}</span>
      )}
    </button>
  );
}

const IconArrow = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M5 12h14M13 6l6 6-6 6" />
  </svg>
);
const IconPlus = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M12 5v14M5 12h14" />
  </svg>
);
const IconDownload = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
  </svg>
);
const IconTrash = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
  </svg>
);
const IconHeart = (filled: boolean) => (
  <svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.6l-1-1a5.5 5.5 0 1 0-7.8 7.8l1 1 7.8 7.8 7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8Z" />
  </svg>
);
const IconStar = (filled: boolean) => (
  <svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M12 3l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18.8 6.2 21.9l1.1-6.5L2.6 9.8l6.5-.9L12 3Z" />
  </svg>
);
const IconCopy = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <rect x="9" y="9" width="12" height="12" rx="2" />
    <path d="M5 15V5a2 2 0 0 1 2-2h10" />
  </svg>
);
const IconCheck = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M5 13l4 4L19 7" />
  </svg>
);
const IconMenu = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-full w-full">
    <path d="M4 7h16M4 12h16M4 17h16" />
  </svg>
);
const IconChevron = (open: boolean) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={["h-full w-full transition-transform duration-200", open ? "rotate-180" : ""].join(" ")}>
    <path d="M6 9l6 6 6-6" />
  </svg>
);

function Panel({ label, children }: { label: string; children: ReactNode }) {
  return (
    <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
      <p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
        {label}
      </p>
      <div className="flex flex-wrap items-center gap-2.5">{children}</div>
    </div>
  );
}

export default function BtnGhostSet() {
  const [liked, setLiked] = useState(false);
  const [starred, setStarred] = useState(true);
  const [copied, setCopied] = useState(false);
  const [expanded, setExpanded] = useState(false);
  const [busy, setBusy] = useState(false);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText("npm i motion");
      setCopied(true);
      setTimeout(() => setCopied(false), 1600);
    } catch {
      setCopied(false);
    }
  };

  const fakeSave = () => {
    if (busy) return;
    setBusy(true);
    setTimeout(() => setBusy(false), 1800);
  };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 dark:bg-slate-950">
      <style>{`
        @keyframes bgs-spin { to { transform: rotate(360deg); } }
        .bgs-spin { animation: bgs-spin 0.7s linear infinite; }
        .bgs-btn { -webkit-tap-highlight-color: transparent; }
        .bgs-pop { animation: bgs-pop 0.28s cubic-bezier(0.34,1.56,0.64,1); }
        @keyframes bgs-pop { 0% { transform: scale(0.6); } 60% { transform: scale(1.18); } 100% { transform: scale(1); } }
        @media (prefers-reduced-motion: reduce) {
          .bgs-spin { animation: none; }
          .bgs-pop { animation: none; }
          .bgs-btn { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-10">
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
            Ghost &amp; text buttons
          </h2>
          <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
            Transparent by default, with a subtle tinted wash on hover. Low-emphasis actions that
            stay quiet until you reach for them.
          </p>
        </header>

        <div className="grid gap-5">
          <Panel label="Sizes">
            <GhostButton size="sm">Small</GhostButton>
            <GhostButton size="md">Medium</GhostButton>
            <GhostButton size="lg">Large</GhostButton>
          </Panel>

          <Panel label="Tones">
            <GhostButton tone="slate">Slate</GhostButton>
            <GhostButton tone="indigo">Indigo</GhostButton>
            <GhostButton tone="violet">Violet</GhostButton>
            <GhostButton tone="emerald">Emerald</GhostButton>
            <GhostButton tone="sky">Sky</GhostButton>
            <GhostButton tone="rose">Rose</GhostButton>
          </Panel>

          <Panel label="With icons">
            <GhostButton tone="indigo" trailing={IconArrow}>
              View changelog
            </GhostButton>
            <GhostButton tone="emerald" leading={IconPlus}>
              New project
            </GhostButton>
            <GhostButton tone="slate" leading={IconDownload}>
              Export CSV
            </GhostButton>
            <GhostButton tone="rose" leading={IconTrash}>
              Delete draft
            </GhostButton>
          </Panel>

          <Panel label="Text links (underline)">
            <GhostButton tone="indigo" size="sm" underline>
              Terms of service
            </GhostButton>
            <GhostButton tone="slate" size="sm" underline>
              Privacy policy
            </GhostButton>
            <GhostButton tone="sky" size="sm" underline trailing={IconArrow}>
              Read the docs
            </GhostButton>
          </Panel>

          <Panel label="States">
            <GhostButton tone="violet">Enabled</GhostButton>
            <GhostButton tone="violet" disabled>
              Disabled
            </GhostButton>
            <GhostButton tone="violet" loading>
              Saving
            </GhostButton>
            <GhostButton tone="emerald" loading={busy} leading={IconDownload} onClick={fakeSave}>
              {busy ? "Syncing" : "Sync now"}
            </GhostButton>
          </Panel>

          <Panel label="Icon-only">
            <button
              type="button"
              aria-label="Open menu"
              className={[base, "h-9 w-9", toneMap.slate.text, toneMap.slate.hover, toneMap.slate.active, toneMap.slate.ring].join(" ")}
            >
              <span className="h-4 w-4" aria-hidden="true">{IconMenu}</span>
            </button>
            <button
              type="button"
              aria-label="Add item"
              className={[base, "h-9 w-9", toneMap.indigo.text, toneMap.indigo.hover, toneMap.indigo.active, toneMap.indigo.ring].join(" ")}
            >
              <span className="h-4 w-4" aria-hidden="true">{IconPlus}</span>
            </button>
            <button
              type="button"
              aria-label="Download file"
              className={[base, "h-9 w-9", toneMap.sky.text, toneMap.sky.hover, toneMap.sky.active, toneMap.sky.ring].join(" ")}
            >
              <span className="h-4 w-4" aria-hidden="true">{IconDownload}</span>
            </button>
          </Panel>

          <Panel label="Interactive — toggles, copy, expand">
            <button
              type="button"
              aria-pressed={liked}
              aria-label={liked ? "Unlike" : "Like"}
              onClick={() => setLiked((v) => !v)}
              className={[
                base,
                sizeMap.md.pad,
                sizeMap.md.text,
                sizeMap.md.gap,
                liked ? toneMap.rose.text : "text-slate-600 dark:text-slate-300",
                toneMap.rose.hover,
                toneMap.rose.active,
                toneMap.rose.ring,
              ].join(" ")}
            >
              <span key={liked ? "on" : "off"} className={["h-4 w-4", liked ? "bgs-pop" : ""].join(" ")} aria-hidden="true">
                {IconHeart(liked)}
              </span>
              {liked ? "Liked" : "Like"}
            </button>

            <button
              type="button"
              aria-pressed={starred}
              aria-label={starred ? "Unstar repository" : "Star repository"}
              onClick={() => setStarred((v) => !v)}
              className={[
                base,
                sizeMap.md.pad,
                sizeMap.md.text,
                sizeMap.md.gap,
                starred ? "text-amber-500 dark:text-amber-300" : "text-slate-600 dark:text-slate-300",
                "hover:bg-amber-500/[0.10] dark:hover:bg-amber-400/[0.14]",
                "active:bg-amber-500/[0.16] dark:active:bg-amber-400/[0.20]",
                "focus-visible:ring-amber-400 dark:focus-visible:ring-amber-500",
              ].join(" ")}
            >
              <span key={starred ? "on" : "off"} className={["h-4 w-4", starred ? "bgs-pop" : ""].join(" ")} aria-hidden="true">
                {IconStar(starred)}
              </span>
              {starred ? "Starred" : "Star"}
            </button>

            <button
              type="button"
              onClick={copy}
              aria-label="Copy install command"
              className={[
                base,
                sizeMap.md.pad,
                sizeMap.md.text,
                sizeMap.md.gap,
                copied ? toneMap.emerald.text : toneMap.slate.text,
                copied ? "" : toneMap.slate.hover,
                copied ? "" : toneMap.slate.active,
                copied ? toneMap.emerald.ring : toneMap.slate.ring,
              ].join(" ")}
            >
              <span className="h-4 w-4" aria-hidden="true">{copied ? IconCheck : IconCopy}</span>
              {copied ? "Copied" : "npm i motion"}
            </button>

            <button
              type="button"
              aria-expanded={expanded}
              onClick={() => setExpanded((v) => !v)}
              className={[
                base,
                sizeMap.md.pad,
                sizeMap.md.text,
                sizeMap.md.gap,
                toneMap.slate.text,
                toneMap.slate.hover,
                toneMap.slate.active,
                toneMap.slate.ring,
              ].join(" ")}
            >
              Details
              <span className="h-4 w-4" aria-hidden="true">{IconChevron(expanded)}</span>
            </button>
          </Panel>

          {expanded && (
            <div className="rounded-2xl border border-slate-200 bg-white px-6 py-5 text-sm leading-relaxed text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
              Ghost buttons keep the interface calm. Reserve solid, high-contrast buttons for the
              single primary action on a screen, and use these for everything secondary: cancel,
              back, filters, row-level actions and inline text links.
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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 →