Web InnoventixFreeCode

Pill Multi Toggle

Original · free

multi-select pill toggles

byWeb InnoventixReact + Tailwind
tglpillmultitoggles
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/tgl-pill-multi.json
tgl-pill-multi.tsx
"use client";

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

type PillOption = {
  value: string;
  label: string;
};

type Tone = "indigo" | "emerald" | "violet";

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

const TONES: Record<
  Tone,
  { on: string; ring: string; accent: string; softDot: string }
> = {
  indigo: {
    on: "border-indigo-600 bg-indigo-600 text-white shadow-sm shadow-indigo-600/25 dark:border-indigo-500 dark:bg-indigo-500",
    ring: "peer-focus-visible:ring-indigo-500 dark:peer-focus-visible:ring-indigo-400",
    accent: "text-indigo-700 dark:text-indigo-300",
    softDot: "bg-indigo-500",
  },
  emerald: {
    on: "border-emerald-600 bg-emerald-600 text-white shadow-sm shadow-emerald-600/25 dark:border-emerald-500 dark:bg-emerald-500",
    ring: "peer-focus-visible:ring-emerald-500 dark:peer-focus-visible:ring-emerald-400",
    accent: "text-emerald-700 dark:text-emerald-300",
    softDot: "bg-emerald-500",
  },
  violet: {
    on: "border-violet-600 bg-violet-600 text-white shadow-sm shadow-violet-600/25 dark:border-violet-500 dark:bg-violet-500",
    ring: "peer-focus-visible:ring-violet-500 dark:peer-focus-visible:ring-violet-400",
    accent: "text-violet-700 dark:text-violet-300",
    softDot: "bg-violet-500",
  },
};

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      aria-hidden="true"
      className="h-3.5 w-3.5"
    >
      <path
        d="M3.5 8.5l3 3 6-7"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function PlusIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      aria-hidden="true"
      className="h-3.5 w-3.5 opacity-55"
    >
      <path
        d="M8 3.5v9M3.5 8h9"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
      />
    </svg>
  );
}

function PillChip({
  option,
  checked,
  disabled,
  tone,
  onToggle,
}: {
  option: PillOption;
  checked: boolean;
  disabled: boolean;
  tone: Tone;
  onToggle: () => void;
}) {
  const reduce = useReducedMotion();
  const t = TONES[tone];

  return (
    <label
      className={`group inline-flex ${
        disabled ? "cursor-not-allowed" : "cursor-pointer"
      } select-none`}
    >
      <input
        type="checkbox"
        className="peer sr-only"
        checked={checked}
        disabled={disabled}
        onChange={onToggle}
      />
      <motion.span
        whileTap={reduce || disabled ? undefined : { scale: 0.94 }}
        className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm font-medium outline-none transition-colors duration-200 peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900 ${
          t.ring
        } ${checked ? t.on : OFF_PILL} ${disabled ? "opacity-45" : ""}`}
      >
        <span className="grid h-3.5 w-3.5 place-items-center">
          {checked ? (
            reduce ? (
              <CheckIcon />
            ) : (
              <motion.span
                initial={{ scale: 0, rotate: -25 }}
                animate={{ scale: 1, rotate: 0 }}
                transition={{ type: "spring", stiffness: 520, damping: 20 }}
                className="grid place-items-center"
              >
                <CheckIcon />
              </motion.span>
            )
          ) : (
            <PlusIcon />
          )}
        </span>
        {option.label}
      </motion.span>
    </label>
  );
}

function PillGroup({
  legend,
  hint,
  options,
  tone,
  selected,
  onChange,
  max,
}: {
  legend: string;
  hint: string;
  options: PillOption[];
  tone: Tone;
  selected: string[];
  onChange: (next: string[]) => void;
  max?: number;
}) {
  const t = TONES[tone];
  const titleId = useId();
  const hintId = useId();
  const atMax = max !== undefined && selected.length >= max;
  const allValues = options.map((o) => o.value);
  const allSelected = selected.length === options.length;

  const toggle = (value: string) => {
    if (selected.includes(value)) {
      onChange(selected.filter((v) => v !== value));
      return;
    }
    if (max !== undefined && selected.length >= max) return;
    onChange([...selected, value]);
  };

  return (
    <div role="group" aria-labelledby={titleId} aria-describedby={hintId}>
      <div className="mb-3 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
        <div className="min-w-0">
          <p
            id={titleId}
            className="text-sm font-semibold text-slate-900 dark:text-slate-100"
          >
            {legend}
          </p>
          <p
            id={hintId}
            className="mt-0.5 text-xs text-slate-500 dark:text-slate-400"
          >
            {hint}
          </p>
        </div>

        <div className="flex items-center gap-3">
          <span
            aria-live="polite"
            className={`text-xs font-semibold tabular-nums ${t.accent}`}
          >
            {max !== undefined
              ? `${selected.length} / ${max}`
              : `${selected.length} selected`}
          </span>

          {max === undefined ? (
            <button
              type="button"
              onClick={() => onChange(allSelected ? [] : allValues)}
              className="rounded text-xs font-medium text-slate-500 underline-offset-4 transition-colors hover:text-slate-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
            >
              {allSelected ? "Clear all" : "Select all"}
            </button>
          ) : selected.length > 0 ? (
            <button
              type="button"
              onClick={() => onChange([])}
              className="rounded text-xs font-medium text-slate-500 underline-offset-4 transition-colors hover:text-slate-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
            >
              Clear
            </button>
          ) : null}
        </div>
      </div>

      <div className="flex flex-wrap gap-2">
        {options.map((o) => {
          const checked = selected.includes(o.value);
          return (
            <PillChip
              key={o.value}
              option={o}
              checked={checked}
              disabled={!checked && atMax}
              tone={tone}
              onToggle={() => toggle(o.value)}
            />
          );
        })}
      </div>

      {max !== undefined && atMax ? (
        <p className="mt-2.5 text-xs font-medium text-amber-600 dark:text-amber-400">
          You&rsquo;ve reached the limit of {max}. Remove one to swap it out.
        </p>
      ) : null}
    </div>
  );
}

const TOPIC_OPTIONS: PillOption[] = [
  { value: "product-design", label: "Product design" },
  { value: "frontend", label: "Frontend" },
  { value: "accessibility", label: "Accessibility" },
  { value: "motion", label: "Motion & interaction" },
  { value: "design-systems", label: "Design systems" },
  { value: "typography", label: "Typography" },
  { value: "research", label: "User research" },
  { value: "prototyping", label: "Prototyping" },
];

const DIET_OPTIONS: PillOption[] = [
  { value: "vegetarian", label: "Vegetarian" },
  { value: "vegan", label: "Vegan" },
  { value: "gluten-free", label: "Gluten-free" },
  { value: "halal", label: "Halal" },
  { value: "nut-free", label: "Nut-free" },
  { value: "dairy-free", label: "Dairy-free" },
  { value: "pescatarian", label: "Pescatarian" },
];

const STACK_OPTIONS: PillOption[] = [
  { value: "react", label: "React" },
  { value: "typescript", label: "TypeScript" },
  { value: "tailwind", label: "Tailwind CSS" },
  { value: "node", label: "Node.js" },
  { value: "postgres", label: "PostgreSQL" },
  { value: "graphql", label: "GraphQL" },
  { value: "redis", label: "Redis" },
  { value: "docker", label: "Docker" },
  { value: "aws", label: "AWS" },
];

const STACK_MAX = 5;

export default function TglPillMulti() {
  const [topics, setTopics] = useState<string[]>(["frontend", "motion"]);
  const [diet, setDiet] = useState<string[]>(["vegetarian"]);
  const [stack, setStack] = useState<string[]>([
    "react",
    "typescript",
    "tailwind",
  ]);
  const [saved, setSaved] = useState(false);

  const total = topics.length + diet.length + stack.length;

  const onTopics = (next: string[]) => {
    setTopics(next);
    setSaved(false);
  };
  const onDiet = (next: string[]) => {
    setDiet(next);
    setSaved(false);
  };
  const onStack = (next: string[]) => {
    setStack(next);
    setSaved(false);
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tglpm-fade-up {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes tglpm-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.4; transform: scale(0.7); }
        }
        .tglpm-fade-up { animation: tglpm-fade-up 260ms ease-out both; }
        .tglpm-pulse { animation: tglpm-pulse 1.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tglpm-fade-up, .tglpm-pulse { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 overflow-hidden"
      >
        <div className="absolute -left-24 -top-16 h-72 w-72 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-500/10" />
        <div className="absolute -right-16 bottom-0 h-72 w-72 rounded-full bg-violet-300/25 blur-3xl dark:bg-violet-500/10" />
      </div>

      <div className="relative mx-auto max-w-3xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wider 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" />
            Preferences
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Tune what lands in your feed
          </h2>
          <p className="mt-2 max-w-xl text-sm text-slate-600 dark:text-slate-400">
            Pick as many pills as you like. Tap once to add, tap again to
            remove. Everything is keyboard-friendly and saves in one click.
          </p>
        </header>

        <div className="rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900/70">
          <div className="space-y-8">
            <PillGroup
              legend="Topics you follow"
              hint="Shape the articles and talks we surface first."
              options={TOPIC_OPTIONS}
              tone="indigo"
              selected={topics}
              onChange={onTopics}
            />

            <hr className="border-slate-200 dark:border-slate-800" />

            <PillGroup
              legend="Dietary preferences"
              hint="Used to filter the lunch menu at events."
              options={DIET_OPTIONS}
              tone="emerald"
              selected={diet}
              onChange={onDiet}
            />

            <hr className="border-slate-200 dark:border-slate-800" />

            <PillGroup
              legend="Primary tech stack"
              hint={`Choose up to ${STACK_MAX} so we can match you to projects.`}
              options={STACK_OPTIONS}
              tone="violet"
              selected={stack}
              onChange={onStack}
              max={STACK_MAX}
            />
          </div>

          <div className="mt-8 flex flex-wrap items-center justify-between gap-4 border-t border-slate-200 pt-6 dark:border-slate-800">
            <div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
              {saved ? (
                <span
                  role="status"
                  className="tglpm-fade-up inline-flex items-center gap-1.5 font-medium text-emerald-600 dark:text-emerald-400"
                >
                  <CheckIcon />
                  Preferences saved
                </span>
              ) : (
                <span className="inline-flex items-center gap-2">
                  <span className="tglpm-pulse h-1.5 w-1.5 rounded-full bg-amber-500" />
                  <span>
                    <span className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                      {total}
                    </span>{" "}
                    selected &middot; unsaved
                  </span>
                </span>
              )}
            </div>

            <button
              type="button"
              onClick={() => setSaved(true)}
              disabled={total === 0 || saved}
              className="inline-flex items-center justify-center gap-2 rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
            >
              {saved ? "Saved" : "Save preferences"}
            </button>
          </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 →