Web InnoventixFreeCode

Toggle Group Button

Original · free

A toggle button group supporting single and multi select.

byWeb InnoventixReact + Tailwind
btntogglegroupbuttons
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-toggle-group.json
btn-toggle-group.tsx
"use client";

import { useId, useState, type ReactNode } from "react";
import { useReducedMotion } from "motion/react";

type Option = {
  value: string;
  label: string;
  icon?: ReactNode;
};

type Accent = "indigo" | "violet" | "emerald" | "rose" | "sky" | "amber";

const ACCENT: Record<
  Accent,
  { on: string; onText: string; ring: string; dot: string }
> = {
  indigo: {
    on: "bg-indigo-600 border-indigo-600 dark:bg-indigo-500 dark:border-indigo-500",
    onText: "text-white",
    ring: "focus-visible:ring-indigo-500",
    dot: "bg-indigo-500",
  },
  violet: {
    on: "bg-violet-600 border-violet-600 dark:bg-violet-500 dark:border-violet-500",
    onText: "text-white",
    ring: "focus-visible:ring-violet-500",
    dot: "bg-violet-500",
  },
  emerald: {
    on: "bg-emerald-600 border-emerald-600 dark:bg-emerald-500 dark:border-emerald-500",
    onText: "text-white",
    ring: "focus-visible:ring-emerald-500",
    dot: "bg-emerald-500",
  },
  rose: {
    on: "bg-rose-600 border-rose-600 dark:bg-rose-500 dark:border-rose-500",
    onText: "text-white",
    ring: "focus-visible:ring-rose-500",
    dot: "bg-rose-500",
  },
  sky: {
    on: "bg-sky-600 border-sky-600 dark:bg-sky-500 dark:border-sky-500",
    onText: "text-white",
    ring: "focus-visible:ring-sky-500",
    dot: "bg-sky-500",
  },
  amber: {
    on: "bg-amber-500 border-amber-500 dark:bg-amber-400 dark:border-amber-400",
    onText: "text-amber-950",
    ring: "focus-visible:ring-amber-500",
    dot: "bg-amber-500",
  },
};

const OFF_CLASSES =
  "bg-white border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900 " +
  "dark:bg-slate-900 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100";

const SIZES = {
  sm: "h-8 px-3 text-xs gap-1.5",
  md: "h-10 px-4 text-sm gap-2",
  lg: "h-12 px-5 text-base gap-2.5",
} as const;

type Size = keyof typeof SIZES;

function ToggleButton({
  option,
  pressed,
  onToggle,
  accent,
  size,
  iconOnly,
}: {
  option: Option;
  pressed: boolean;
  onToggle: () => void;
  accent: Accent;
  size: Size;
  iconOnly?: boolean;
}) {
  const a = ACCENT[accent];
  return (
    <button
      type="button"
      aria-pressed={pressed}
      aria-label={iconOnly ? option.label : undefined}
      onClick={onToggle}
      className={[
        "btg-btn relative inline-flex items-center justify-center rounded-lg border 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",
        "active:scale-[0.97]",
        SIZES[size],
        iconOnly ? "aspect-square px-0" : "",
        a.ring,
        pressed ? `${a.on} ${a.onText} shadow-sm` : OFF_CLASSES,
      ].join(" ")}
    >
      {option.icon ? (
        <span className="shrink-0" aria-hidden="true">
          {option.icon}
        </span>
      ) : null}
      {iconOnly ? null : <span>{option.label}</span>}
    </button>
  );
}

/* ---------- inline glyphs ---------- */

function IconBold() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
      <path d="M7 5h6a3.5 3.5 0 0 1 0 7H7z" />
      <path d="M7 12h7a3.5 3.5 0 0 1 0 7H7z" />
    </svg>
  );
}
function IconItalic() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="15" y1="5" x2="9" y2="19" />
      <line x1="9" y1="5" x2="17" y2="5" />
      <line x1="7" y1="19" x2="15" y2="19" />
    </svg>
  );
}
function IconUnderline() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M7 4v6a5 5 0 0 0 10 0V4" />
      <line x1="5" y1="20" x2="19" y2="20" />
    </svg>
  );
}
function IconStrike() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M6 12h12" />
      <path d="M8 7a4 3 0 0 1 8 0" />
      <path d="M8 16a4 3 0 0 0 8 0" />
    </svg>
  );
}
function IconAlignLeft() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="4" y1="6" x2="20" y2="6" />
      <line x1="4" y1="12" x2="13" y2="12" />
      <line x1="4" y1="18" x2="16" y2="18" />
    </svg>
  );
}
function IconAlignCenter() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="4" y1="6" x2="20" y2="6" />
      <line x1="7" y1="12" x2="17" y2="12" />
      <line x1="5" y1="18" x2="19" y2="18" />
    </svg>
  );
}
function IconAlignRight() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="4" y1="6" x2="20" y2="6" />
      <line x1="11" y1="12" x2="20" y2="12" />
      <line x1="8" y1="18" x2="20" y2="18" />
    </svg>
  );
}
function IconAlignJustify() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="4" y1="6" x2="20" y2="6" />
      <line x1="4" y1="12" x2="20" y2="12" />
      <line x1="4" y1="18" x2="20" y2="18" />
    </svg>
  );
}
function IconGrid() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <rect x="4" y="4" width="7" height="7" rx="1" />
      <rect x="13" y="4" width="7" height="7" rx="1" />
      <rect x="4" y="13" width="7" height="7" rx="1" />
      <rect x="13" y="13" width="7" height="7" rx="1" />
    </svg>
  );
}
function IconList() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="9" y1="6" x2="20" y2="6" />
      <line x1="9" y1="12" x2="20" y2="12" />
      <line x1="9" y1="18" x2="20" y2="18" />
      <circle cx="5" cy="6" r="1" />
      <circle cx="5" cy="12" r="1" />
      <circle cx="5" cy="18" r="1" />
    </svg>
  );
}

/* ---------- single-select demo (segmented) ---------- */

const VIEW_OPTIONS: Option[] = [
  { value: "grid", label: "Grid", icon: <IconGrid /> },
  { value: "list", label: "List", icon: <IconList /> },
];

const ALIGN_OPTIONS: Option[] = [
  { value: "left", label: "Align left", icon: <IconAlignLeft /> },
  { value: "center", label: "Align center", icon: <IconAlignCenter /> },
  { value: "right", label: "Align right", icon: <IconAlignRight /> },
  { value: "justify", label: "Justify", icon: <IconAlignJustify /> },
];

const PLAN_OPTIONS: Option[] = [
  { value: "monthly", label: "Monthly" },
  { value: "annual", label: "Annual" },
];

/* ---------- multi-select demo (formatting) ---------- */

const FORMAT_OPTIONS: Option[] = [
  { value: "bold", label: "Bold", icon: <IconBold /> },
  { value: "italic", label: "Italic", icon: <IconItalic /> },
  { value: "underline", label: "Underline", icon: <IconUnderline /> },
  { value: "strike", label: "Strikethrough", icon: <IconStrike /> },
];

function SingleGroup({
  options,
  value,
  onChange,
  accent,
  size,
  iconOnly,
  label,
}: {
  options: Option[];
  value: string;
  onChange: (v: string) => void;
  accent: Accent;
  size: Size;
  iconOnly?: boolean;
  label: string;
}) {
  return (
    <div
      role="group"
      aria-label={label}
      className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-slate-100/70 p-1 dark:border-slate-800 dark:bg-slate-900/70"
    >
      {options.map((opt) => (
        <ToggleButton
          key={opt.value}
          option={opt}
          pressed={value === opt.value}
          onToggle={() => onChange(opt.value)}
          accent={accent}
          size={size}
          iconOnly={iconOnly}
        />
      ))}
    </div>
  );
}

function MultiGroup({
  options,
  selected,
  onToggle,
  accent,
  size,
  iconOnly,
  label,
}: {
  options: Option[];
  selected: Set<string>;
  onToggle: (v: string) => void;
  accent: Accent;
  size: Size;
  iconOnly?: boolean;
  label: string;
}) {
  return (
    <div
      role="group"
      aria-label={label}
      className="inline-flex flex-wrap items-center gap-2"
    >
      {options.map((opt) => (
        <ToggleButton
          key={opt.value}
          option={opt}
          pressed={selected.has(opt.value)}
          onToggle={() => onToggle(opt.value)}
          accent={accent}
          size={size}
          iconOnly={iconOnly}
        />
      ))}
    </div>
  );
}

export default function BtnToggleGroup() {
  const reduce = useReducedMotion();
  const uid = useId();

  const [view, setView] = useState("grid");
  const [align, setAlign] = useState("left");
  const [plan, setPlan] = useState("annual");

  const [format, setFormat] = useState<Set<string>>(new Set(["bold"]));
  const [days, setDays] = useState<Set<string>>(new Set(["mon", "wed", "fri"]));

  const toggleFormat = (v: string) =>
    setFormat((prev) => {
      const next = new Set(prev);
      if (next.has(v)) next.delete(v);
      else next.add(v);
      return next;
    });

  const toggleDay = (v: string) =>
    setDays((prev) => {
      const next = new Set(prev);
      if (next.has(v)) next.delete(v);
      else next.add(v);
      return next;
    });

  const DAY_OPTIONS: Option[] = [
    { value: "mon", label: "Mon" },
    { value: "tue", label: "Tue" },
    { value: "wed", label: "Wed" },
    { value: "thu", label: "Thu" },
    { value: "fri", label: "Fri" },
    { value: "sat", label: "Sat" },
    { value: "sun", label: "Sun" },
  ];

  const formatSummary =
    format.size === 0
      ? "None"
      : FORMAT_OPTIONS.filter((o) => format.has(o.value))
          .map((o) => o.label)
          .join(", ");

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-24">
      <style>{`
        @keyframes btg-rise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .btg-card { animation: btg-rise 0.5s cubic-bezier(0.22,1,0.36,1) both; }
        .btg-btn { will-change: transform; }
        @media (prefers-reduced-motion: reduce) {
          .btg-card { animation: none !important; }
          .btg-btn { transition: none !important; }
          .btg-btn:active { transform: none !important; }
        }
      `}</style>

      <div className="mx-auto flex max-w-3xl flex-col gap-10">
        <header className="flex flex-col gap-3">
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Toggle group
          </span>
          <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">
            Single &amp; multi-select toggles
          </h2>
          <p className="max-w-xl text-sm text-slate-500 dark:text-slate-400">
            Tactile, keyboard-accessible toggle buttons. Single-select groups
            behave like a segmented control; multi-select groups let any number
            of buttons stay pressed. Every button reports its state with{" "}
            <code className="rounded bg-slate-200 px-1 py-0.5 text-[0.7rem] text-slate-700 dark:bg-slate-800 dark:text-slate-300">
              aria-pressed
            </code>
            .
          </p>
        </header>

        {/* Single select — segmented */}
        <div
          className="btg-card flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          style={reduce ? undefined : { animationDelay: "40ms" }}
        >
          <div className="flex items-center justify-between gap-4">
            <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
              Single select
            </h3>
            <span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
              one active
            </span>
          </div>

          <div className="flex flex-col gap-2">
            <label
              id={`${uid}-view`}
              className="text-xs font-medium text-slate-500 dark:text-slate-400"
            >
              View mode
            </label>
            <SingleGroup
              label="View mode"
              options={VIEW_OPTIONS}
              value={view}
              onChange={setView}
              accent="indigo"
              size="md"
            />
          </div>

          <div className="flex flex-col gap-2">
            <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
              Text alignment (icon-only)
            </span>
            <SingleGroup
              label="Text alignment"
              options={ALIGN_OPTIONS}
              value={align}
              onChange={setAlign}
              accent="violet"
              size="md"
              iconOnly
            />
          </div>

          <div className="flex flex-col gap-2">
            <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
              Billing period
            </span>
            <SingleGroup
              label="Billing period"
              options={PLAN_OPTIONS}
              value={plan}
              onChange={setPlan}
              accent="emerald"
              size="lg"
            />
            <p className="text-xs text-slate-500 dark:text-slate-400">
              {plan === "annual"
                ? "Billed $180/yr — save 25% vs monthly."
                : "Billed $20/mo — cancel anytime."}
            </p>
          </div>
        </div>

        {/* Multi select */}
        <div
          className="btg-card flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          style={reduce ? undefined : { animationDelay: "120ms" }}
        >
          <div className="flex items-center justify-between gap-4">
            <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
              Multi select
            </h3>
            <span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
              any active
            </span>
          </div>

          <div className="flex flex-col gap-2">
            <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
              Text formatting
            </span>
            <MultiGroup
              label="Text formatting"
              options={FORMAT_OPTIONS}
              selected={format}
              onToggle={toggleFormat}
              accent="sky"
              size="md"
              iconOnly
            />
            <p className="text-xs text-slate-500 dark:text-slate-400" aria-live="polite">
              Active: <span className="font-medium text-slate-700 dark:text-slate-200">{formatSummary}</span>
            </p>
          </div>

          <div className="flex flex-col gap-2">
            <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
              Active days ({days.size} selected)
            </span>
            <MultiGroup
              label="Active days"
              options={DAY_OPTIONS}
              selected={days}
              onToggle={toggleDay}
              accent="amber"
              size="sm"
            />
          </div>
        </div>

        {/* Sizes reference */}
        <div
          className="btg-card flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          style={reduce ? undefined : { animationDelay: "200ms" }}
        >
          <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
            Sizes
          </h3>
          <div className="flex flex-col gap-4">
            {(["sm", "md", "lg"] as Size[]).map((s) => (
              <div key={s} className="flex items-center gap-4">
                <span className="w-8 text-xs font-medium uppercase text-slate-400">
                  {s}
                </span>
                <SingleGroup
                  label={`View mode ${s}`}
                  options={VIEW_OPTIONS}
                  value={view}
                  onChange={setView}
                  accent="rose"
                  size={s}
                />
              </div>
            ))}
          </div>
        </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 →