Web InnoventixFreeCode

Checkbox Card Toggle

Original · free

selectable card checkboxes

byWeb InnoventixReact + Tailwind
tglcheckboxcardtoggles
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-checkbox-card.json
tgl-checkbox-card.tsx
"use client";

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

type Accent = "indigo" | "emerald";

function cx(...parts: Array<string | false | null | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

const cardChecked: Record<Accent, string> = {
  indigo:
    "border-indigo-500 bg-indigo-50 dark:border-indigo-500/70 dark:bg-indigo-500/10",
  emerald:
    "border-emerald-500 bg-emerald-50 dark:border-emerald-500/70 dark:bg-emerald-500/10",
};

const focusRing: Record<Accent, string> = {
  indigo: "peer-focus-visible:ring-indigo-500",
  emerald: "peer-focus-visible:ring-emerald-500",
};

const boxChecked: Record<Accent, string> = {
  indigo:
    "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500",
  emerald:
    "border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500",
};

const iconTint: Record<Accent, string> = {
  indigo:
    "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  emerald:
    "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
};

function IconChart({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path d="M4 20V10M10 20V4M16 20v-7M22 20H2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconLifebuoy({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
      <circle cx="12" cy="12" r="3.5" stroke="currentColor" strokeWidth="2" />
      <path d="M9.5 9.5 5.6 5.6M14.5 9.5l3.9-3.9M9.5 14.5l-3.9 3.9M14.5 14.5l3.9 3.9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

function IconShield({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path d="M12 3 5 6v5c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6l-7-3Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
      <path d="m9 12 2 2 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconDatabase({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <ellipse cx="12" cy="5.5" rx="7" ry="2.8" stroke="currentColor" strokeWidth="2" />
      <path d="M5 5.5v13c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8v-13" stroke="currentColor" strokeWidth="2" />
      <path d="M5 12c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8" stroke="currentColor" strokeWidth="2" />
    </svg>
  );
}

function CheckBox({ checked, accent }: { checked: boolean; accent: Accent }) {
  return (
    <span
      data-on={checked}
      className={cx(
        "tglcc-box mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors duration-200",
        checked
          ? boxChecked[accent]
          : "border-slate-300 bg-white text-transparent dark:border-slate-600 dark:bg-slate-800"
      )}
    >
      <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
        <path
          d="M5 12.5 9.2 16.7 19 7"
          className="tglcc-tick"
          stroke="currentColor"
          strokeWidth="2.6"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </span>
  );
}

interface OptionCardProps {
  leading: ReactNode;
  title: string;
  description: string;
  meta?: string;
  badge?: string;
  accent: Accent;
  checked: boolean;
  onToggle: () => void;
  describedById: string;
}

function OptionCard({
  leading,
  title,
  description,
  meta,
  badge,
  accent,
  checked,
  onToggle,
  describedById,
}: OptionCardProps) {
  return (
    <label className="block cursor-pointer">
      <input
        type="checkbox"
        checked={checked}
        onChange={onToggle}
        aria-describedby={describedById}
        className="sr-only peer"
      />
      <div
        className={cx(
          "relative flex items-start gap-4 rounded-2xl border p-4 transition-[border-color,background-color,box-shadow] duration-200",
          "peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 dark:peer-focus-visible:ring-offset-slate-950",
          focusRing[accent],
          checked
            ? cx(cardChecked[accent], "shadow-sm")
            : "border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600"
        )}
      >
        <span
          className={cx(
            "flex h-11 w-11 shrink-0 items-center justify-center rounded-xl [&>svg]:h-5 [&>svg]:w-5",
            iconTint[accent]
          )}
        >
          {leading}
        </span>
        <div className="min-w-0 flex-1">
          <div className="flex flex-wrap items-center gap-2">
            <h4 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
              {title}
            </h4>
            {badge ? (
              <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-700 dark:bg-amber-400/15 dark:text-amber-300">
                {badge}
              </span>
            ) : null}
          </div>
          <p
            id={describedById}
            className="mt-1 text-sm leading-snug text-slate-500 dark:text-slate-400"
          >
            {description}
          </p>
          {meta ? (
            <p className="mt-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
              {meta}
            </p>
          ) : null}
        </div>
        <CheckBox checked={checked} accent={accent} />
      </div>
    </label>
  );
}

function InterestChip({
  label,
  checked,
  onToggle,
}: {
  label: string;
  checked: boolean;
  onToggle: () => void;
}) {
  return (
    <label className="cursor-pointer">
      <input
        type="checkbox"
        checked={checked}
        onChange={onToggle}
        className="sr-only peer"
      />
      <span
        className={cx(
          "inline-flex select-none items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm font-medium transition duration-200",
          "peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500 peer-focus-visible:ring-offset-2 dark:peer-focus-visible:ring-offset-slate-950",
          checked
            ? "border-violet-500 bg-violet-600 text-white dark:border-violet-500 dark:bg-violet-500"
            : "border-slate-300 bg-white text-slate-700 hover:border-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600"
        )}
      >
        <span data-on={checked} className="tglcc-box inline-flex h-4 w-4 items-center justify-center">
          <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
            {checked ? (
              <path
                d="M5 12.5 9.2 16.7 19 7"
                className="tglcc-tick"
                stroke="currentColor"
                strokeWidth="2.6"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            ) : (
              <path
                d="M12 5v14M5 12h14"
                stroke="currentColor"
                strokeWidth="2.2"
                strokeLinecap="round"
                opacity="0.55"
              />
            )}
          </svg>
        </span>
        {label}
      </span>
    </label>
  );
}

interface Addon {
  id: string;
  title: string;
  description: string;
  price: number;
  badge?: string;
  icon: ReactNode;
}

const addonOptions: Addon[] = [
  {
    id: "analytics",
    title: "Advanced analytics",
    description: "Cohorts, funnels, and retention dashboards updated hourly.",
    price: 29,
    badge: "Popular",
    icon: <IconChart />,
  },
  {
    id: "support",
    title: "Priority support",
    description: "24/7 live chat with a 15-minute first-response SLA.",
    price: 49,
    icon: <IconLifebuoy />,
  },
  {
    id: "sso",
    title: "SSO & SAML",
    description: "Okta, Microsoft Entra ID, and Google Workspace login.",
    price: 39,
    icon: <IconShield />,
  },
  {
    id: "storage",
    title: "Expanded storage",
    description: "1 TB of encrypted object storage with versioning.",
    price: 12,
    icon: <IconDatabase />,
  },
];

interface Member {
  id: string;
  name: string;
  role: string;
  initials: string;
}

const memberOptions: Member[] = [
  { id: "maya", name: "Maya Okonkwo", role: "Design Lead", initials: "MO" },
  { id: "devon", name: "Devon Ramirez", role: "Backend Engineer", initials: "DR" },
  { id: "priya", name: "Priya Nair", role: "Data Scientist", initials: "PN" },
  { id: "leo", name: "Leo Fischer", role: "Product Manager", initials: "LF" },
];

const interestOptions: string[] = [
  "Product design",
  "Frontend",
  "Machine learning",
  "DevOps",
  "Accessibility",
  "Data viz",
  "Security",
  "Motion design",
];

function toggleInSet(prev: Set<string>, id: string): Set<string> {
  const next = new Set(prev);
  if (next.has(id)) {
    next.delete(id);
  } else {
    next.add(id);
  }
  return next;
}

export default function TglCheckboxCard() {
  const uid = useId();
  const prefersReduced = useReducedMotion();

  const [addons, setAddons] = useState<Set<string>>(
    () => new Set(["analytics", "sso"])
  );
  const [members, setMembers] = useState<Set<string>>(() => new Set(["maya"]));
  const [interests, setInterests] = useState<Set<string>>(
    () => new Set(["Frontend", "Motion design"])
  );

  const allAddonsSelected = addons.size === addonOptions.length;
  const noAddonsSelected = addons.size === 0;
  const monthlyTotal = addonOptions
    .filter((o) => addons.has(o.id))
    .reduce((sum, o) => sum + o.price, 0);

  const listV: Variants = {
    hidden: {},
    show: { transition: { staggerChildren: 0.05, delayChildren: 0.03 } },
  };
  const itemV: Variants = prefersReduced
    ? { hidden: { opacity: 1, y: 0 }, show: { opacity: 1, y: 0 } }
    : {
        hidden: { opacity: 0, y: 10 },
        show: { opacity: 1, y: 0, transition: { duration: 0.35, ease: "easeOut" } },
      };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-white to-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 lg:py-24 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100">
      <style>{`
        @keyframes tglcc-draw { to { stroke-dashoffset: 0; } }
        @keyframes tglcc-pop {
          0% { transform: scale(0.5); opacity: 0; }
          60% { transform: scale(1.12); }
          100% { transform: scale(1); opacity: 1; }
        }
        .tglcc-tick { stroke-dasharray: 26; stroke-dashoffset: 26; }
        .tglcc-box[data-on="true"] .tglcc-tick {
          animation: tglcc-draw 360ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
        }
        .tglcc-box[data-on="true"] { animation: tglcc-pop 220ms ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .tglcc-tick { animation: none !important; stroke-dashoffset: 0 !important; }
          .tglcc-box[data-on="true"] { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mb-12 max-w-2xl">
          <span className="inline-flex items-center gap-2 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-700 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Toggles
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Selectable card checkboxes
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Multi-select cards backed by real checkbox inputs. Fully keyboard
            operable&mdash;tab to a card and press space&mdash;with visible focus
            rings, live selection summaries, and animated check marks.
          </p>
        </header>

        <div className="grid gap-8 lg:grid-cols-5">
          {/* Variant 1 - Plan add-ons */}
          <fieldset className="rounded-3xl border border-slate-200 bg-slate-100/40 p-4 sm:p-6 lg:col-span-3 dark:border-slate-800 dark:bg-slate-900/40">
            <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
              <legend className="text-base font-semibold text-slate-900 dark:text-slate-100">
                Configure your workspace
              </legend>
              <div className="flex items-center gap-1">
                <button
                  type="button"
                  onClick={() => setAddons(new Set(addonOptions.map((o) => o.id)))}
                  disabled={allAddonsSelected}
                  className="rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  Select all
                </button>
                <button
                  type="button"
                  onClick={() => setAddons(new Set())}
                  disabled={noAddonsSelected}
                  className="rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  Clear
                </button>
              </div>
            </div>

            <motion.div
              variants={listV}
              initial="hidden"
              whileInView="show"
              viewport={{ once: true, amount: 0.15 }}
              className="grid gap-3 sm:grid-cols-2"
            >
              {addonOptions.map((o) => (
                <motion.div key={o.id} variants={itemV}>
                  <OptionCard
                    leading={o.icon}
                    title={o.title}
                    description={o.description}
                    meta={`$${o.price} / mo`}
                    badge={o.badge}
                    accent="indigo"
                    checked={addons.has(o.id)}
                    onToggle={() => setAddons((prev) => toggleInSet(prev, o.id))}
                    describedById={`${uid}-addon-${o.id}`}
                  />
                </motion.div>
              ))}
            </motion.div>

            <div className="mt-4 flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-800/60">
              <p
                aria-live="polite"
                className="text-sm text-slate-600 dark:text-slate-400"
              >
                <span className="font-semibold text-slate-900 dark:text-slate-100">
                  {addons.size}
                </span>{" "}
                {addons.size === 1 ? "add-on" : "add-ons"} selected
              </p>
              <p className="text-right">
                <span className="text-lg font-bold text-slate-900 dark:text-white">
                  ${monthlyTotal}
                </span>
                <span className="text-sm text-slate-500 dark:text-slate-400"> / mo</span>
              </p>
            </div>
          </fieldset>

          {/* Variant 2 - Invite teammates */}
          <fieldset className="rounded-3xl border border-slate-200 bg-slate-100/40 p-4 sm:p-6 lg:col-span-2 dark:border-slate-800 dark:bg-slate-900/40">
            <legend className="mb-4 text-base font-semibold text-slate-900 dark:text-slate-100">
              Invite teammates
            </legend>
            <motion.div
              variants={listV}
              initial="hidden"
              whileInView="show"
              viewport={{ once: true, amount: 0.15 }}
              className="grid gap-3"
            >
              {memberOptions.map((m) => (
                <motion.div key={m.id} variants={itemV}>
                  <OptionCard
                    leading={
                      <span className="text-sm font-semibold">{m.initials}</span>
                    }
                    title={m.name}
                    description={m.role}
                    accent="emerald"
                    checked={members.has(m.id)}
                    onToggle={() => setMembers((prev) => toggleInSet(prev, m.id))}
                    describedById={`${uid}-member-${m.id}`}
                  />
                </motion.div>
              ))}
            </motion.div>
            <p
              aria-live="polite"
              className="mt-4 text-sm text-slate-600 dark:text-slate-400"
            >
              <span className="font-semibold text-slate-900 dark:text-slate-100">
                {members.size}
              </span>{" "}
              {members.size === 1 ? "person" : "people"} will be invited
            </p>
          </fieldset>
        </div>

        {/* Variant 3 - Interest chips */}
        <fieldset className="mt-8 rounded-3xl border border-slate-200 bg-slate-100/40 p-4 sm:p-6 dark:border-slate-800 dark:bg-slate-900/40">
          <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
            <legend className="text-base font-semibold text-slate-900 dark:text-slate-100">
              Tune your feed
            </legend>
            <p
              aria-live="polite"
              className="text-sm text-slate-500 dark:text-slate-400"
            >
              {interests.size} selected
            </p>
          </div>
          <div className="flex flex-wrap gap-2">
            {interestOptions.map((label) => (
              <InterestChip
                key={label}
                label={label}
                checked={interests.has(label)}
                onToggle={() => setInterests((prev) => toggleInSet(prev, label))}
              />
            ))}
          </div>
        </fieldset>
      </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 →