Filter Chip
Original · freeselectable filter chips
byWeb InnoventixReact + Tailwind
chipfilterbadges
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.jsonchip-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} · {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 quoteSimilar components
Browse all →
Basic Badge
Originalbasic badges across colours

Dot Badge
Originaldot status badges

Count Badge
Originalnotification count badges

Status Badge
Originalstatus pills (active/pending/error)

Pill Badge
Originalsoft pill badges

Outline Badge
Originaloutline badges

Gradient Badge
Originalgradient badges
Icon Badge
Originalbadges with leading icons

Removable Badge
Originalremovable chip badges

Input Chip
Originalchip input tokens

Cloud Tag
Originalweighted tag cloud

Notification Badge
Originalicon with notification badge

