Pill Tag Button

Selectable filter chips with count badges and a check that slides in on a spring.

  • Buttons
  • Real estate
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/pill-tag-button.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/buttons/pill-tag-button.tsx
1/**
2 * PillTagButton — selectable filter chips with count badges and an animated
3 * check that slides in when a chip is selected.
4 *
5 * Props: options[] ({ id, label, count?, icon?, disabled? }), value (string[]),
6 * onChange(next), multiple (default true), size ("sm" | "md"), tone
7 * ("neutral" | "accent"), aria-label, theme. Also exports <PillTag/> for a
8 * single chip (selected, onToggle).
9 *
10 * Browser support: all evergreen browsers.
11 * Accessibility: each chip is a <button aria-pressed> inside role="group"
12 * (multiple) or a role="radiogroup" of role="radio" chips (single). Counts are
13 * read as part of the name ("Condos, 64"). Visible focus, 44px-wide touch
14 * target on md via padding. Reduced motion: check appears instantly.
15 */
16import { useState } from "react";
17import type { ReactNode } from "react";
18
19export interface PillOption {
20 id: string;
21 label: string;
22 count?: number;
23 icon?: ReactNode;
24 disabled?: boolean;
25}
26
27export interface PillTagProps {
28 label: string;
29 count?: number;
30 icon?: ReactNode;
31 selected?: boolean;
32 disabled?: boolean;
33 onToggle?: () => void;
34 size?: "sm" | "md";
35 tone?: "neutral" | "accent";
36 radio?: boolean;
37}
38
39export function PillTag({ label, count, icon, selected, disabled, onToggle, size = "md", tone = "neutral", radio }: PillTagProps) {
40 return (
41 <button
42 type="button"
43 className="fd-pill"
44 data-size={size}
45 data-tone={tone}
46 data-selected={selected || undefined}
47 disabled={disabled}
48 onClick={onToggle}
49 {...(radio ? { role: "radio", "aria-checked": !!selected } : { "aria-pressed": !!selected })}
50 >
51 <span className="fd-pill-check" aria-hidden="true">
52 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
53 </span>
54 {icon && <span className="fd-pill-ic">{icon}</span>}
55 <span className="fd-pill-label">{label}</span>
56 {count !== undefined && <span className="fd-pill-count">{count}</span>}
57 </button>
58 );
59}
60
61export interface PillTagButtonProps {
62 options: PillOption[];
63 value: string[];
64 onChange: (next: string[]) => void;
65 multiple?: boolean;
66 size?: "sm" | "md";
67 tone?: "neutral" | "accent";
68 theme?: "light" | "dark";
69 className?: string;
70 "aria-label"?: string;
71}
72
73export function PillTagButton({ options, value, onChange, multiple = true, size = "md", tone = "neutral", theme, className, "aria-label": label }: PillTagButtonProps) {
74 const toggle = (id: string) => {
75 if (multiple) onChange(value.includes(id) ? value.filter((v) => v !== id) : [...value, id]);
76 else onChange(value.includes(id) ? [] : [id]);
77 };
78 return (
79 <div className={`fd-pill-group${className ? " " + className : ""}`} role={multiple ? "group" : "radiogroup"} aria-label={label} data-fd-theme={theme}>
80 <style>{css}</style>
81 {options.map((o) => (
82 <PillTag key={o.id} label={o.label} count={o.count} icon={o.icon} disabled={o.disabled} size={size} tone={tone} radio={!multiple} selected={value.includes(o.id)} onToggle={() => toggle(o.id)} />
83 ))}
84 </div>
85 );
86}
87
88const css = `
89:where(.fd-pill-group,.fd-pill-group *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
90:where(.fd-pill-group,.fd-pill-group *)::before,:where(.fd-pill-group,.fd-pill-group *)::after{box-sizing:border-box}
91:where(.fd-pill-group svg){display:block;flex-shrink:0}
92:where(.fd-pill-group button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
93.fd-pill-group{
94 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
95 --p-bg:#fff;--p-fg:#0B0B0F;--p-mut:#6B6B76;--p-line:rgba(11,11,15,.12);--p-hover:#F7F7F9;--p-count:rgba(11,11,15,.07);
96 --p-on-bg:#0B0B0F;--p-on-fg:#fff;--p-on-count:rgba(255,255,255,.2);--p-ring:rgba(0,122,255,.7);
97 display:flex;flex-wrap:wrap;gap:8px;
98 font:500 14px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;color:var(--p-fg);
99}
100[data-fd-theme="dark"] .fd-pill-group,.fd-pill-group[data-fd-theme="dark"]{--p-bg:#16161B;--p-fg:#F5F5F7;--p-mut:#9A9AA6;--p-line:rgba(255,255,255,.12);--p-hover:#1E1E25;--p-count:rgba(255,255,255,.09);--p-on-bg:#F5F5F7;--p-on-fg:#0B0B0F;--p-on-count:rgba(11,11,15,.14);--p-ring:rgba(10,132,255,.85)}
101.fd-pill{position:relative;display:inline-flex;align-items:center;gap:0;flex-shrink:0;
102 appearance:none;-webkit-appearance:none;border:0;outline:0;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;white-space:nowrap;
103 height:var(--p-h);padding:0 var(--p-px);border-radius:9999px;background:var(--p-bg);color:var(--p-fg);
104 font-family:inherit;font-size:var(--p-fs);font-weight:500;line-height:1;letter-spacing:inherit;
105 box-shadow:0 0 0 1px var(--p-line),0 1px 2px rgba(11,11,15,.04);
106 transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-2) var(--fd-ease-out),color var(--fd-dur-2) var(--fd-ease-out),box-shadow var(--fd-dur-2) var(--fd-ease-out)}
107.fd-pill[data-size="sm"]{--p-h:28px;--p-px:12px;--p-fs:13px;--p-chk:14px}
108.fd-pill[data-size="md"]{--p-h:36px;--p-px:16px;--p-fs:14px;--p-chk:16px}
109.fd-pill[data-tone="accent"]{--p-on-bg:#007AFF;--p-on-fg:#fff;--p-on-count:rgba(255,255,255,.25)}
110[data-fd-theme="dark"] .fd-pill[data-tone="accent"]{--p-on-bg:#0A84FF}
111@media (hover:hover){.fd-pill:hover:not(:disabled):not([data-selected]){background:var(--p-hover)}}
112.fd-pill[data-selected]{background:var(--p-on-bg);color:var(--p-on-fg);box-shadow:0 0 0 1px var(--p-on-bg),0 4px 12px -6px var(--p-on-bg)}
113.fd-pill:active:not(:disabled){transform:scale(.96);transition:transform var(--fd-dur-1) var(--fd-ease-out)}
114.fd-pill:focus-visible{outline:2px solid var(--p-ring);outline-offset:2px}
115.fd-pill:disabled{opacity:.4;cursor:not-allowed}
116.fd-pill-check{display:grid;place-items:center;width:0;height:var(--p-chk);opacity:0;overflow:hidden;transform:scale(.4);
117 transition:width var(--fd-dur-3) var(--fd-spring),opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring),margin var(--fd-dur-3) var(--fd-spring)}
118.fd-pill[data-selected] .fd-pill-check{width:var(--p-chk);margin-right:6px;opacity:1;transform:scale(1)}
119.fd-pill-check path{stroke-dasharray:24;stroke-dashoffset:24;transition:stroke-dashoffset var(--fd-dur-2) var(--fd-ease-out)}
120.fd-pill[data-selected] .fd-pill-check path{stroke-dashoffset:0;transition-delay:90ms}
121.fd-pill-ic{display:grid;place-items:center;width:16px;height:16px;margin-right:6px;color:var(--p-mut);transition:color var(--fd-dur-2) var(--fd-ease-out)}
122.fd-pill-ic svg{width:100%;height:100%}.fd-pill[data-selected] .fd-pill-ic{color:inherit}
123.fd-pill-label{display:block}
124.fd-pill-count{display:inline-grid;place-items:center;min-width:22px;height:20px;margin-left:8px;margin-right:-6px;padding:0 6px;border-radius:9999px;background:var(--p-count);color:var(--p-mut);font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:0;
125 transition:background-color var(--fd-dur-2) var(--fd-ease-out),color var(--fd-dur-2) var(--fd-ease-out)}
126.fd-pill[data-selected] .fd-pill-count{background:var(--p-on-count);color:inherit}
127@media (prefers-reduced-motion:reduce){.fd-pill,.fd-pill:active:not(:disabled),.fd-pill-check,.fd-pill-check path{transition:none;transform:none}.fd-pill[data-selected] .fd-pill-check{transform:none}}
128@media (prefers-contrast:more){.fd-pill[data-selected]{box-shadow:0 0 0 2px currentColor}}
129`;
130
131/* ───────────────────────────── demo ───────────────────────────── */
132
133type Listing = { id: string; addr: string; hood: string; price: number; beds: number; baths: number; sqft: number; type: string; tags: string[]; hue: number };
134const LISTINGS: Listing[] = [
135 { id: "l1", addr: "2208 Rainey St #4C", hood: "Rainey District", price: 612000, beds: 2, baths: 2, sqft: 1104, type: "condo", tags: ["parking", "pool", "pets"], hue: 212 },
136 { id: "l2", addr: "4410 Duval St", hood: "Hyde Park", price: 925000, beds: 3, baths: 2, sqft: 1860, type: "house", tags: ["pets", "yard"], hue: 28 },
137 { id: "l3", addr: "1700 E 6th St #12", hood: "East Austin", price: 489000, beds: 1, baths: 1, sqft: 842, type: "loft", tags: ["new", "parking"], hue: 164 },
138 { id: "l4", addr: "908 Barton Springs Rd", hood: "Zilker", price: 1380000, beds: 4, baths: 3, sqft: 2610, type: "house", tags: ["pool", "pets", "yard", "parking"], hue: 100 },
139 { id: "l5", addr: "3301 Manor Rd Unit 7", hood: "Windsor Park", price: 538000, beds: 2, baths: 2, sqft: 1290, type: "townhome", tags: ["new", "parking", "pets"], hue: 330 },
140 { id: "l6", addr: "711 W Cesar Chavez #1905", hood: "Downtown", price: 799000, beds: 2, baths: 2, sqft: 1320, type: "condo", tags: ["pool", "parking", "new"], hue: 250 },
141 { id: "l7", addr: "5215 Avenue F", hood: "Hyde Park", price: 1095000, beds: 3, baths: 3, sqft: 2140, type: "house", tags: ["yard", "parking", "pets"], hue: 48 },
142 { id: "l8", addr: "1109 S 1st St #3", hood: "SoCo", price: 574000, beds: 2, baths: 1, sqft: 1010, type: "townhome", tags: ["pets", "yard"], hue: 190 },
143];
144const TYPES: PillOption[] = ["house", "condo", "townhome", "loft"].map((t) => ({ id: t, label: t === "townhome" ? "Townhomes" : t === "loft" ? "Lofts" : t === "house" ? "Houses" : "Condos", count: LISTINGS.filter((l) => l.type === t).length }));
145const glyph = (p: string) => (
146 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d={p} /></svg>
147);
148const AMENITIES: PillOption[] = [
149 { id: "pets", label: "Pet friendly", icon: glyph("M11 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM16 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM7 10a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM20 10a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM12 11c-3 0-5 3-5 5 0 2 1.5 3 3 3 1 0 1.4-.5 2-.5s1 .5 2 .5c1.5 0 3-1 3-3 0-2-2-5-5-5z") },
150 { id: "pool", label: "Pool", icon: glyph("M2 18c1.5 0 1.5 1 3 1s1.5-1 3-1 1.5 1 3 1 1.5-1 3-1 1.5 1 3 1 1.5-1 3-1M8 14V6a2 2 0 0 1 4 0M16 14V6a2 2 0 0 0-4 0M8 9h8") },
151 { id: "parking", label: "Parking", icon: glyph("M5 4h8a5 5 0 0 1 0 10H8v6M5 4v16") },
152 { id: "yard", label: "Yard", icon: glyph("M12 21v-8M12 13c-3 0-5-2-5-5 3 0 5 2 5 5zM12 13c3 0 5-2 5-5-3 0-5 2-5 5zM6 21h12") },
153 { id: "new", label: "New build", icon: glyph("M12 3l2.5 5.5L20 9l-4 4 1 6-5-3-5 3 1-6-4-4 5.5-.5z") },
154];
155const money = (n: number) => "$" + n.toLocaleString("en-US");
156
157export function Demo() {
158 const [theme, setTheme] = useState<"light" | "dark">("light");
159 const [types, setTypes] = useState<string[]>(["condo", "townhome"]);
160 const [am, setAm] = useState<string[]>([]);
161 const [beds, setBeds] = useState<string[]>([]);
162
163 const shown = LISTINGS.filter((l) => (types.length === 0 || types.includes(l.type)) && am.every((a) => l.tags.includes(a)) && (beds.length === 0 || l.beds >= Number(beds[0])));
164 const any = types.length + am.length + beds.length > 0;
165
166 return (
167 <div className="fd-pill-demo" data-fd-theme={theme}>
168 <style>{demoCss}</style>
169 <header className="fd-pill-demo-bar">
170 <span className="fd-pill-demo-brand">Openhaus</span>
171 <span className="fd-pill-demo-loc">Austin, TX</span>
172 <button type="button" className="fd-pill-demo-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>
173 </header>
174
175 <main className="fd-pill-demo-main">
176 <h1>Homes for sale in Austin</h1>
177 <div className="fd-pill-demo-filters">
178 <div className="fd-pill-demo-frow">
179 <span id="fd-pill-t">Home type</span>
180 <PillTagButton aria-label="Home type" options={TYPES} value={types} onChange={setTypes} />
181 </div>
182 <div className="fd-pill-demo-frow">
183 <span id="fd-pill-a">Must have</span>
184 <PillTagButton aria-label="Amenities" options={AMENITIES} value={am} onChange={setAm} tone="accent" size="sm" />
185 </div>
186 <div className="fd-pill-demo-frow">
187 <span id="fd-pill-b">Bedrooms</span>
188 <PillTagButton aria-label="Minimum bedrooms" multiple={false} options={[{ id: "2", label: "2+" }, { id: "3", label: "3+" }, { id: "4", label: "4+" }]} value={beds} onChange={setBeds} size="sm" />
189 </div>
190 </div>
191
192 <div className="fd-pill-demo-count" aria-live="polite">
193 <span><b>{shown.length}</b> {shown.length === 1 ? "home" : "homes"}</span>
194 {any && <button type="button" onClick={() => { setTypes([]); setAm([]); setBeds([]); }}>Clear all</button>}
195 </div>
196
197 {shown.length === 0 ? (
198 <p className="fd-pill-demo-empty">No homes match every filter. Try removing an amenity.</p>
199 ) : (
200 <ul className="fd-pill-demo-grid">
201 {shown.map((l) => (
202 <li key={l.id} className="fd-pill-demo-card">
203 <div className="fd-pill-demo-img" style={{ ["--hue" as string]: l.hue }} aria-hidden="true">
204 <svg viewBox="0 0 120 60" width="100%" height="100%" preserveAspectRatio="xMidYMax slice"><path d="M0 60V38l22-14 18 10 24-22 26 20 30-12v40z" fill="currentColor" opacity=".35" /><path d="M0 60V46l30-10 26 12 28-14 36 10v16z" fill="currentColor" opacity=".55" /></svg>
205 <span>{l.type === "townhome" ? "Townhome" : l.type[0].toUpperCase() + l.type.slice(1)}</span>
206 </div>
207 <div className="fd-pill-demo-info">
208 <b>{money(l.price)}</b>
209 <span>{l.beds} bd · {l.baths} ba · {l.sqft.toLocaleString("en-US")} sqft</span>
210 <small>{l.addr}, {l.hood}</small>
211 </div>
212 </li>
213 ))}
214 </ul>
215 )}
216 </main>
217 </div>
218 );
219}
220
221const demoCss = `
222:where(.fd-pill-demo,.fd-pill-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
223:where(.fd-pill-demo,.fd-pill-demo *)::before,:where(.fd-pill-demo,.fd-pill-demo *)::after{box-sizing:border-box}
224:where(.fd-pill-demo svg){display:block;flex-shrink:0}
225:where(.fd-pill-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
226.fd-pill-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;
227 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;
228 --d-bg:#F7F7F9;--d-card:#fff;--d-ink:#0B0B0F;--d-mut:#6B6B76;--d-line:rgba(11,11,15,.08);--l:92%;--c:.04;
229 min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}
230.fd-pill-demo[data-fd-theme="dark"]{--d-bg:#09090B;--d-card:#111115;--d-ink:#F5F5F7;--d-mut:#9A9AA6;--d-line:rgba(255,255,255,.09);--l:30%}
231.fd-pill-demo-bar{display:flex;align-items:center;gap:16px;height:60px;padding:0 clamp(16px,4vw,40px);border-bottom:1px solid var(--d-line)}
232.fd-pill-demo-brand{font:700 18px/1 var(--d-disp);letter-spacing:-.03em}
233.fd-pill-demo-loc{margin-right:auto;font:400 14px/1 var(--d-font);color:var(--d-mut)}
234.fd-pill-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}
235.fd-pill-demo-theme:hover{color:var(--d-ink)}.fd-pill-demo-theme:focus-visible,.fd-pill-demo-count button:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px;border-radius:6px}
236.fd-pill-demo-main{max-width:1040px;margin:0 auto;padding:36px clamp(16px,4vw,40px) 64px}
237.fd-pill-demo-main h1{font:600 clamp(24px,4vw,32px)/1.15 var(--d-disp);letter-spacing:-.025em}
238.fd-pill-demo-filters{display:flex;flex-direction:column;gap:14px;margin-top:24px;padding:20px;border-radius:20px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(0,0,0,.03)}
239.fd-pill-demo-frow{display:grid;grid-template-columns:96px minmax(0,1fr);align-items:center;gap:12px}
240.fd-pill-demo-frow>span{font:500 13px/1 var(--d-font);color:var(--d-mut)}
241.fd-pill-demo-count{display:flex;align-items:center;gap:16px;margin:24px 0 16px;font:400 14px/1 var(--d-font);color:var(--d-mut)}
242.fd-pill-demo-count b{color:var(--d-ink);font-weight:600;font-variant-numeric:tabular-nums}
243.fd-pill-demo-count button{cursor:pointer;font:500 14px/1 var(--d-font);color:#007AFF}
244.fd-pill-demo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(228px,1fr));gap:16px}
245.fd-pill-demo-card{border-radius:20px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 1px 2px rgba(0,0,0,.03);overflow:hidden;animation:fd-pill-in 420ms var(--fd-ease-out) both}
246.fd-pill-demo-img{position:relative;height:120px;color:hsl(var(--hue) 35% 45%);background:hsl(var(--hue) 40% var(--l))}
247.fd-pill-demo-img span{position:absolute;left:12px;top:12px;height:22px;display:inline-flex;align-items:center;padding:0 9px;border-radius:9999px;background:rgba(255,255,255,.85);color:#0B0B0F;font:600 11px/1 var(--d-font)}
248.fd-pill-demo-info{display:flex;flex-direction:column;gap:6px;padding:14px 16px 16px}
249.fd-pill-demo-info b{font:600 20px/1.1 var(--d-disp);letter-spacing:-.02em;font-variant-numeric:tabular-nums}
250.fd-pill-demo-info span{font:400 14px/1.3 var(--d-font)}
251.fd-pill-demo-info small{font:400 13px/1.3 var(--d-font);color:var(--d-mut)}
252.fd-pill-demo-empty{padding:48px 0;text-align:center;color:var(--d-mut)}
253@keyframes fd-pill-in{from{opacity:0;transform:translateY(8px)}}
254@media (max-width:640px){.fd-pill-demo-frow{grid-template-columns:1fr;gap:8px}.fd-pill-demo-filters{padding:16px}}
255@media (prefers-reduced-motion:reduce){.fd-pill-demo-card{animation:none}}
256`;

More Buttons. Same file contract.

Button Core

The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.

Shimmer Button

A dark capsule with a travelling light sweep and a rotating border glow for your one important call to action.

Magnetic Button

A button that leans toward your cursor on a spring, with the label drifting further than the shell.