Web InnoventixFreeCode

Filter Chip

Original · free

selectable filter chips

byWeb InnoventixReact + Tailwind
chipfilterbadges
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/chip-filter.json
chip-filter.tsx
"use client";

import { useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

interface Resource {
  id: string;
  title: string;
  author: string;
  minutes: number;
  tags: string[];
}

const RESOURCES: Resource[] = [
  {
    id: "r1",
    title: "Building color systems that survive dark mode",
    author: "Priya Nandakumar",
    minutes: 11,
    tags: ["Design", "Accessibility"],
  },
  {
    id: "r2",
    title: "Roving tabindex, explained with real keyboards",
    author: "Marcus Feld",
    minutes: 9,
    tags: ["Frontend", "Accessibility"],
  },
  {
    id: "r3",
    title: "Shipping an inference gateway on a $40 budget",
    author: "Dana Okonkwo",
    minutes: 14,
    tags: ["AI/ML", "DevOps"],
  },
  {
    id: "r4",
    title: "Columnar storage for people who hate JOINs",
    author: "Leo Vargas",
    minutes: 18,
    tags: ["Data", "DevOps"],
  },
  {
    id: "r5",
    title: "Fine-tuning small models beats one giant prompt",
    author: "Amara Bright",
    minutes: 13,
    tags: ["AI/ML", "Data"],
  },
  {
    id: "r6",
    title: "Motion that respects prefers-reduced-motion",
    author: "Jonas Riel",
    minutes: 7,
    tags: ["Frontend", "Design", "Accessibility"],
  },
  {
    id: "r7",
    title: "A pragmatic guide to zero-downtime migrations",
    author: "Sofia Kaur",
    minutes: 16,
    tags: ["DevOps", "Data"],
  },
  {
    id: "r8",
    title: "Type-safe forms without a single 'any'",
    author: "Elias Thorne",
    minutes: 12,
    tags: ["Frontend"],
  },
  {
    id: "r9",
    title: "Auditing focus order with a screen reader",
    author: "Ngozi Amadi",
    minutes: 10,
    tags: ["Accessibility", "Design"],
  },
];

const TAG_ORDER = [
  "Design",
  "Frontend",
  "AI/ML",
  "Data",
  "DevOps",
  "Accessibility",
] as const;

type MatchMode = "any" | "all";

export default function ChipFilter() {
  const reduce = useReducedMotion();

  const [selected, setSelected] = useState<string[]>([]);
  const [matchMode, setMatchMode] = useState<MatchMode>("any");
  const [focusIndex, setFocusIndex] = useState(0);

  const chipRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const counts = useMemo(() => {
    const map = new Map<string, number>();
    for (const tag of TAG_ORDER) map.set(tag, 0);
    for (const item of RESOURCES) {
      for (const tag of item.tags) {
        map.set(tag, (map.get(tag) ?? 0) + 1);
      }
    }
    return map;
  }, []);

  const filtered = useMemo(() => {
    if (selected.length === 0) return RESOURCES;
    return RESOURCES.filter((item) => {
      if (matchMode === "all") {
        return selected.every((tag) => item.tags.includes(tag));
      }
      return selected.some((tag) => item.tags.includes(tag));
    });
  }, [selected, matchMode]);

  function toggle(tag: string) {
    setSelected((prev) =>
      prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
    );
  }

  function onChipKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    const count = TAG_ORDER.length;
    let next = index;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      next = (index + 1) % count;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      next = (index - 1 + count) % count;
    } else if (event.key === "Home") {
      next = 0;
    } else if (event.key === "End") {
      next = count - 1;
    } else {
      return;
    }
    event.preventDefault();
    setFocusIndex(next);
    chipRefs.current[next]?.focus();
  }

  const chipMotion = reduce
    ? {}
    : {
        initial: { opacity: 0, y: 6 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -6 },
        transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] as const },
      };

  const ring =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-8 lg:py-28">
      <style>{`
        @keyframes cf-pop {
          0% { transform: scale(0.6); opacity: 0; }
          60% { transform: scale(1.15); }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes cf-sheen {
          0% { background-position: -160% 0; }
          100% { background-position: 260% 0; }
        }
        .cf-pop { animation: cf-pop 320ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        .cf-sheen {
          background-image: linear-gradient(110deg, transparent 30%, rgba(99,102,241,0.18) 50%, transparent 70%);
          background-size: 200% 100%;
          animation: cf-sheen 2.6s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .cf-pop, .cf-sheen { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <svg
              viewBox="0 0 24 24"
              aria-hidden="true"
              className="h-3.5 w-3.5"
              fill="none"
              stroke="currentColor"
              strokeWidth={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="10" y1="18" x2="14" y2="18" />
            </svg>
            Reading list
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight sm:text-3xl">
            Filter the engineering library
          </h2>
          <p className="mt-2 max-w-xl text-sm text-slate-600 dark:text-slate-400">
            Pick one or more topics to narrow the list. Use the arrow keys to move
            between chips and Space or Enter to toggle each one.
          </p>
        </header>

        <div className="mb-5 flex flex-wrap items-center justify-between gap-3">
          <div
            role="group"
            aria-label="Filter resources by topic"
            className="flex flex-wrap gap-2.5"
          >
            {TAG_ORDER.map((tag, index) => {
              const isOn = selected.includes(tag);
              return (
                <button
                  key={tag}
                  type="button"
                  ref={(node) => {
                    chipRefs.current[index] = node;
                  }}
                  aria-pressed={isOn}
                  tabIndex={focusIndex === index ? 0 : -1}
                  onClick={() => toggle(tag)}
                  onFocus={() => setFocusIndex(index)}
                  onKeyDown={(event) => onChipKeyDown(event, index)}
                  className={[
                    "group inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors duration-150",
                    ring,
                    isOn
                      ? "border-indigo-500 bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 hover:bg-indigo-500 dark:border-indigo-400 dark:bg-indigo-500"
                      : "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-300 dark:hover:border-slate-600 dark:hover:bg-slate-800",
                  ].join(" ")}
                >
                  <span
                    aria-hidden="true"
                    className={[
                      "grid h-4 w-4 place-items-center rounded-full text-[10px]",
                      isOn
                        ? "cf-pop bg-white/25 text-white"
                        : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
                    ].join(" ")}
                  >
                    {isOn ? (
                      <svg
                        viewBox="0 0 24 24"
                        className="h-3 w-3"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={3}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      >
                        <polyline points="20 6 9 17 4 12" />
                      </svg>
                    ) : (
                      counts.get(tag)
                    )}
                  </span>
                  {tag}
                </button>
              );
            })}
          </div>

          <AnimatePresence>
            {selected.length > 0 && (
              <motion.button
                {...chipMotion}
                type="button"
                onClick={() => setSelected([])}
                className={[
                  "inline-flex items-center gap-1.5 rounded-full border border-rose-300 bg-rose-50 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20",
                  ring,
                ].join(" ")}
              >
                <svg
                  viewBox="0 0 24 24"
                  aria-hidden="true"
                  className="h-3.5 w-3.5"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2.5}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <line x1="18" y1="6" x2="6" y2="18" />
                  <line x1="6" y1="6" x2="18" y2="18" />
                </svg>
                Clear {selected.length}
              </motion.button>
            )}
          </AnimatePresence>
        </div>

        <div className="mb-6 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 dark:border-slate-800">
          <div
            role="radiogroup"
            aria-label="Match mode"
            className="inline-flex rounded-lg border border-slate-300 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-900"
          >
            {(["any", "all"] as const).map((mode) => {
              const active = matchMode === mode;
              return (
                <button
                  key={mode}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  onClick={() => setMatchMode(mode)}
                  className={[
                    "rounded-md px-3 py-1 text-xs font-semibold capitalize transition-colors",
                    ring,
                    active
                      ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                      : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200",
                  ].join(" ")}
                >
                  Match {mode}
                </button>
              );
            })}
          </div>

          <p
            role="status"
            aria-live="polite"
            className="text-sm font-medium text-slate-600 dark:text-slate-400"
          >
            <span className="font-bold text-slate-900 dark:text-white">
              {filtered.length}
            </span>{" "}
            {filtered.length === 1 ? "article" : "articles"}
            {selected.length > 0 ? (
              <span> matching {matchMode === "all" ? "all" : "any"} topic</span>
            ) : null}
          </p>
        </div>

        <motion.ul layout={!reduce} className="space-y-3">
          <AnimatePresence mode="popLayout">
            {filtered.map((item) => (
              <motion.li
                key={item.id}
                layout={!reduce}
                initial={reduce ? false : { opacity: 0, scale: 0.97 }}
                animate={reduce ? {} : { opacity: 1, scale: 1 }}
                exit={reduce ? {} : { opacity: 0, scale: 0.97 }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
              >
                <div className="flex items-start justify-between gap-4">
                  <div className="min-w-0">
                    <h3 className="truncate text-base font-semibold text-slate-900 dark:text-white">
                      {item.title}
                    </h3>
                    <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
                      {item.author} &middot; {item.minutes} min read
                    </p>
                  </div>
                  <span className="shrink-0 rounded-md bg-slate-100 px-2 py-1 text-xs font-semibold tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                    {item.minutes}m
                  </span>
                </div>
                <div className="mt-3 flex flex-wrap gap-1.5">
                  {item.tags.map((tag) => {
                    const on = selected.includes(tag);
                    return (
                      <span
                        key={tag}
                        className={[
                          "rounded-full px-2 py-0.5 text-xs font-medium",
                          on
                            ? "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300"
                            : "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
                        ].join(" ")}
                      >
                        {tag}
                      </span>
                    );
                  })}
                </div>
              </motion.li>
            ))}
          </AnimatePresence>
        </motion.ul>

        {filtered.length === 0 && (
          <div className="cf-sheen rounded-xl border border-dashed border-slate-300 bg-white px-6 py-12 text-center dark:border-slate-700 dark:bg-slate-900">
            <p className="text-sm font-medium text-slate-600 dark:text-slate-400">
              No articles match every selected topic. Try{" "}
              <button
                type="button"
                onClick={() => setMatchMode("any")}
                className={[
                  "font-semibold text-indigo-600 underline underline-offset-2 dark:text-indigo-400",
                  ring,
                ].join(" ")}
              >
                matching any topic
              </button>{" "}
              instead.
            </p>
          </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 →