Web InnoventixFreeCode

Outline Set Button

Original · free

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

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

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

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

const tones: Record<
  Tone,
  {
    label: string;
    text: string;
    border: string;
    ring: string;
    fill: string;
    hoverText: string;
  }
> = {
  indigo: {
    label: "Indigo",
    text: "text-indigo-600 dark:text-indigo-300",
    border: "border-indigo-300 dark:border-indigo-500/60",
    ring: "focus-visible:ring-indigo-500/50",
    fill: "bg-indigo-600 dark:bg-indigo-500",
    hoverText: "group-hover:text-white group-focus-visible:text-white",
  },
  violet: {
    label: "Violet",
    text: "text-violet-600 dark:text-violet-300",
    border: "border-violet-300 dark:border-violet-500/60",
    ring: "focus-visible:ring-violet-500/50",
    fill: "bg-violet-600 dark:bg-violet-500",
    hoverText: "group-hover:text-white group-focus-visible:text-white",
  },
  emerald: {
    label: "Emerald",
    text: "text-emerald-600 dark:text-emerald-300",
    border: "border-emerald-300 dark:border-emerald-500/60",
    ring: "focus-visible:ring-emerald-500/50",
    fill: "bg-emerald-600 dark:bg-emerald-500",
    hoverText: "group-hover:text-white group-focus-visible:text-white",
  },
  rose: {
    label: "Rose",
    text: "text-rose-600 dark:text-rose-300",
    border: "border-rose-300 dark:border-rose-500/60",
    ring: "focus-visible:ring-rose-500/50",
    fill: "bg-rose-600 dark:bg-rose-500",
    hoverText: "group-hover:text-white group-focus-visible:text-white",
  },
  amber: {
    label: "Amber",
    text: "text-amber-600 dark:text-amber-300",
    border: "border-amber-300 dark:border-amber-500/60",
    ring: "focus-visible:ring-amber-500/50",
    fill: "bg-amber-500 dark:bg-amber-500",
    hoverText:
      "group-hover:text-zinc-900 group-focus-visible:text-zinc-900 dark:group-hover:text-zinc-900 dark:group-focus-visible:text-zinc-900",
  },
  sky: {
    label: "Sky",
    text: "text-sky-600 dark:text-sky-300",
    border: "border-sky-300 dark:border-sky-500/60",
    ring: "focus-visible:ring-sky-500/50",
    fill: "bg-sky-600 dark:bg-sky-500",
    hoverText: "group-hover:text-white group-focus-visible:text-white",
  },
};

const sizeStyles: Record<Size, string> = {
  sm: "h-9 px-4 text-sm gap-1.5 rounded-lg",
  md: "h-11 px-5 text-sm gap-2 rounded-xl",
  lg: "h-14 px-7 text-base gap-2.5 rounded-2xl",
};

function Spinner({ className = "" }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className={`bos-spin h-4 w-4 ${className}`}
      fill="none"
      aria-hidden="true"
    >
      <circle
        cx="12"
        cy="12"
        r="9"
        stroke="currentColor"
        strokeWidth="3"
        className="opacity-25"
      />
      <path
        d="M21 12a9 9 0 0 0-9-9"
        stroke="currentColor"
        strokeWidth="3"
        strokeLinecap="round"
      />
    </svg>
  );
}

type OutlineButtonProps = {
  tone: Tone;
  size: Size;
  children: ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  loading?: boolean;
  pressed?: boolean;
  ariaLabel?: string;
  leading?: ReactNode;
  full?: boolean;
};

function OutlineButton({
  tone,
  size,
  children,
  onClick,
  disabled = false,
  loading = false,
  pressed,
  ariaLabel,
  leading,
  full = false,
}: OutlineButtonProps) {
  const t = tones[tone];
  const isDisabled = disabled || loading;

  return (
    <button
      type="button"
      onClick={onClick}
      disabled={isDisabled}
      aria-label={ariaLabel}
      aria-busy={loading || undefined}
      aria-pressed={pressed}
      className={[
        "group relative isolate inline-flex select-none items-center justify-center overflow-hidden",
        "border bg-transparent font-semibold tracking-tight",
        "transition-[transform,box-shadow,border-color] duration-200 ease-out",
        "outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950",
        "active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45 disabled:active:scale-100",
        sizeStyles[size],
        t.text,
        t.border,
        t.ring,
        full ? "w-full" : "",
        pressed ? "shadow-inner" : "",
      ].join(" ")}
    >
      {/* hover fill sweep */}
      <span
        aria-hidden="true"
        className={[
          "bos-fill absolute inset-0 -z-10 origin-left scale-x-0 rounded-[inherit]",
          "transition-transform duration-300 ease-out",
          "group-hover:scale-x-100 group-focus-visible:scale-x-100",
          "group-disabled:!scale-x-0",
          pressed ? "scale-x-100" : "",
          t.fill,
        ].join(" ")}
      />
      <span
        className={[
          "relative inline-flex items-center justify-center gap-[inherit]",
          "transition-colors duration-200 ease-out",
          isDisabled ? "" : t.hoverText,
          pressed
            ? tone === "amber"
              ? "text-zinc-900"
              : "text-white"
            : "",
        ].join(" ")}
      >
        {loading ? <Spinner /> : leading}
        {children}
      </span>
    </button>
  );
}

export default function BtnOutlineSet() {
  const [loadingTone, setLoadingTone] = useState<Tone | null>(null);
  const [saved, setSaved] = useState(false);
  const [followed, setFollowed] = useState(false);
  const [size, setSize] = useState<Size>("md");

  const toneOrder: Tone[] = [
    "indigo",
    "violet",
    "emerald",
    "rose",
    "amber",
    "sky",
  ];
  const sizeOrder: Size[] = ["sm", "md", "lg"];

  const runLoad = (tone: Tone) => {
    setLoadingTone(tone);
    window.setTimeout(() => setLoadingTone(null), 1600);
  };

  return (
    <section className="relative w-full bg-white px-5 py-20 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 sm:px-8">
      <style>{`
        @keyframes bos-spin { to { transform: rotate(360deg); } }
        .bos-spin { animation: bos-spin 0.7s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .bos-spin { animation: none; }
          .bos-fill { transition-duration: 1ms; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-14 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1 text-xs font-medium text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            FreeCode / Buttons
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Outline button set
          </h2>
          <p className="mt-3 text-base leading-relaxed text-zinc-500 dark:text-zinc-400">
            Ghost outlines that fill from the left on hover and focus. Six tones,
            three sizes, with real loading, disabled and pressed states.
          </p>
        </header>

        {/* Tone matrix */}
        <div className="mb-16">
          <div className="mb-5 flex items-center justify-between gap-4">
            <h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
              Tones
            </h3>
            {/* size switcher */}
            <div
              role="group"
              aria-label="Preview button size"
              className="inline-flex rounded-xl border border-zinc-200 bg-zinc-50 p-1 dark:border-zinc-800 dark:bg-zinc-900"
            >
              {sizeOrder.map((s) => (
                <button
                  key={s}
                  type="button"
                  onClick={() => setSize(s)}
                  aria-pressed={size === s}
                  className={[
                    "rounded-lg px-3 py-1 text-xs font-semibold uppercase tracking-wide outline-none transition-colors",
                    "focus-visible:ring-2 focus-visible:ring-indigo-500/50",
                    size === s
                      ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-white"
                      : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
                  ].join(" ")}
                >
                  {s}
                </button>
              ))}
            </div>
          </div>

          <div className="flex flex-wrap gap-4 rounded-2xl border border-zinc-200 bg-zinc-50/60 p-8 dark:border-zinc-800 dark:bg-zinc-900/40">
            {toneOrder.map((tone) => (
              <OutlineButton key={tone} tone={tone} size={size}>
                {tones[tone].label}
              </OutlineButton>
            ))}
          </div>
        </div>

        {/* Sizes column */}
        <div className="mb-16 grid gap-10 sm:grid-cols-2">
          <div>
            <h3 className="mb-5 text-sm font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
              Sizes
            </h3>
            <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-zinc-200 bg-zinc-50/60 p-8 dark:border-zinc-800 dark:bg-zinc-900/40">
              {sizeOrder.map((s) => (
                <OutlineButton key={s} tone="violet" size={s}>
                  {s === "sm" ? "Small" : s === "md" ? "Medium" : "Large"}
                </OutlineButton>
              ))}
            </div>
          </div>

          <div>
            <h3 className="mb-5 text-sm font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
              States
            </h3>
            <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-zinc-200 bg-zinc-50/60 p-8 dark:border-zinc-800 dark:bg-zinc-900/40">
              <OutlineButton tone="sky" size="md">
                Default
              </OutlineButton>
              <OutlineButton
                tone="sky"
                size="md"
                loading={loadingTone === "sky"}
                onClick={() => runLoad("sky")}
              >
                {loadingTone === "sky" ? "Saving" : "Click to load"}
              </OutlineButton>
              <OutlineButton tone="sky" size="md" disabled>
                Disabled
              </OutlineButton>
            </div>
          </div>
        </div>

        {/* With icons + real toggles */}
        <div>
          <h3 className="mb-5 text-sm font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
            In context
          </h3>
          <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-zinc-200 bg-zinc-50/60 p-8 dark:border-zinc-800 dark:bg-zinc-900/40">
            {/* Save toggle */}
            <OutlineButton
              tone="emerald"
              size="md"
              pressed={saved}
              onClick={() => setSaved((v) => !v)}
              leading={
                <svg
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill={saved ? "currentColor" : "none"}
                  stroke="currentColor"
                  strokeWidth="2"
                  aria-hidden="true"
                >
                  <path
                    d="M6 3h12a1 1 0 0 1 1 1v16l-7-4-7 4V4a1 1 0 0 1 1-1Z"
                    strokeLinejoin="round"
                  />
                </svg>
              }
            >
              {saved ? "Saved" : "Save"}
            </OutlineButton>

            {/* Follow toggle */}
            <OutlineButton
              tone="rose"
              size="md"
              pressed={followed}
              onClick={() => setFollowed((v) => !v)}
              leading={
                <svg
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill={followed ? "currentColor" : "none"}
                  stroke="currentColor"
                  strokeWidth="2"
                  aria-hidden="true"
                >
                  <path
                    d="M12 20s-7-4.35-9.5-8.5C1 8.5 2.5 5 6 5c2 0 3.2 1.2 4 2.3C10.8 6.2 12 5 14 5c3.5 0 5 3.5 3.5 6.5C19 15.65 12 20 12 20Z"
                    strokeLinejoin="round"
                  />
                </svg>
              }
            >
              {followed ? "Following" : "Follow"}
            </OutlineButton>

            {/* Async action */}
            <OutlineButton
              tone="indigo"
              size="md"
              loading={loadingTone === "indigo"}
              onClick={() => runLoad("indigo")}
              leading={
                <svg
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  aria-hidden="true"
                >
                  <path
                    d="M12 5v14M5 12h14"
                    strokeLinecap="round"
                  />
                </svg>
              }
            >
              {loadingTone === "indigo" ? "Working" : "New project"}
            </OutlineButton>

            {/* Icon only */}
            <OutlineButton
              tone="amber"
              size="md"
              ariaLabel="Add to favourites"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-4 w-4"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                aria-hidden="true"
              >
                <path
                  d="m12 3 2.6 5.3 5.9.9-4.2 4.1 1 5.8L12 16.9 6.7 19.6l1-5.8L3.5 9.7l5.9-.9L12 3Z"
                  strokeLinejoin="round"
                  strokeLinecap="round"
                />
              </svg>
            </OutlineButton>
          </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 →