Prototype Library

Pill

Compact, rounded tag with color variants, sizes, an optional leading icon, and an optional remove control.

Inherited .astro import EsaPill from '@esa/ecology/esa-pill.astro';

Preview

Wetlands
<Pill label="Wetlands" variant="success" removable={false} />

Variants

Default Primary Success Warning Danger Info
<Pill label="Default" variant="default" removable={false} />
<Pill label="Primary" variant="primary" removable={false} />
<Pill label="Success" variant="success" removable={false} />
<Pill label="Warning" variant="warning" removable={false} />
<Pill label="Danger" variant="danger" removable={false} />
<Pill label="Info" variant="info" removable={false} />

Sizes

Extra small Small Medium Large
<Pill label="Extra small" size="xs" removable={false} />
<Pill label="Small" size="sm" removable={false} />
<Pill label="Medium" size="md" removable={false} />
<Pill label="Large" size="lg" removable={false} />

Shape

Pills are squared by default — a 4px corner (--radius-sm), the same one badge uses, so chips across a table or a filter bar read as one quiet family. shape="round" opts into the fully rounded capsule (--radius-pill) where the tag is the emphasis rather than the data beside it. Both follow the tier-2 radius roles, so a squared-off brand re-points one role and every pill follows.

Squared Rounded
<Pill label="Squared" variant="primary" removable={false} />
<Pill label="Rounded" variant="primary" shape="round" removable={false} />
Extra small Small Medium Large
<Pill label="Extra small" size="xs" shape="round" removable={false} />
<Pill label="Small" size="sm" shape="round" removable={false} />
<Pill label="Medium" size="md" shape="round" removable={false} />
<Pill label="Large" size="lg" shape="round" removable={false} />

Icon & removable

Pass a leading icon via the icon slot. With removable (the default), a × button emits a removed event and removes the pill from the DOM.

Tagged Wetlands Overdue
<Pill label="Tagged" variant="primary" removable={false}>
  <svg slot="icon">…</svg>
</Pill>
<Pill label="Wetlands" variant="success" />
<Pill label="Overdue" variant="danger" />

Custom colors

variant covers the six system intents and is deliberately closed — success means succeeded, not "green". A project's own vocabulary is an open set the hub can't enumerate: permit types, survey stages, species, funding sources. That's what category is for.

Pass a category name and the pill emits data-category. Your theme scopes a three-step ramp to that name; the pill reads steps 2 / 6 / 11 for fill, border and text — the same triple every built-in variant uses, so your category renders as a native member of the set rather than a foreign object.

Pick a scale, don't pick colors. Tier 1 ships the whole Radix palette — all 25 chromatic hues and all 6 neutral temperatures, twelve steps each, in light and dark. Point the three names at one of them and you're done — no hex anywhere:

Permit expired Awaiting survey Prescribed burn
<Pill label="Permit expired" category="permit-expired" removable={false} />
<Pill label="Awaiting survey" category="awaiting-survey" removable={false} />

/* in your <project>-theme.css — one block per category */
[data-category="permit-expired"] {
  --category-2:  var(--color-iris-2);
  --category-6:  var(--color-iris-6);
  --category-11: var(--color-iris-11);
}

Start with iris, gold or plum. They carry no borrowed meaning: no semantic role points at them, and the hub's default chart series doesn't seat them. Eight scales are spoken for — grass (brand), blue (info), green (success), yellow (warning), red (danger), copper (AI), orange (accent) and gray (chrome). Reusing one makes a category look like a status it isn't, which is worse than leaving it neutral.

A second, softer conflict: crimson, cyan, indigo, teal and violet are seated in the hub's default --color-background-dataviz-* series, so a category in one of those will match a chart line on the same page. That's a resemblance, not a false status — take it if the hue is right and the page has no charts.

There used to be a middle rung here and there isn't any more. When tier 1 shipped eleven scales, reaching the rest meant @import '@radix-ui/colors/purple.css' in your own theme. Since 2026-08-17 the whole palette is generated into tier 1, so --color-purple-2/6/11 is already declared and the import is dead weight. Need a brand color that isn't a Radix hue at all? Only then reach for radix-ui.com/colors/custom, which fits your color to a 12-step perceptual scale; copy steps 2, 6 and 11 and rename the --accent- prefix.

Both rungs exist to avoid the same thing: a fill and a text color chosen independently. That is how dark purple ends up on near-black, and nothing in the build will tell you.

Know where the line is. Step 11 is tuned to APCA, not WCAG 2, so on step 2 it sits at the AA boundary rather than above it. Measured across all 26 chromatic scales tier 1 ships, six miss 4.5:1 for normal text: orange 4.25, teal 4.34, yellow 4.43, amber 4.43, jade 4.43 and green 4.49. cyan lands exactly on 4.50. The rest pass, most of them by a hair — blue 4.53, lime 4.56, grass 4.82 — and the widest margin in the palette is violet at 5.86. The three starters above sit near the top of that range: violet-adjacent iris 5.74, plum 5.69, gold 5.60.

This is a property of the scale, not of this mechanism — the built-in warning variant measures 4.43 and always has, and success joined it at 4.49 when it moved off lime. Step 12 clears AA on every one of the 26 at 10.44:1 or better (the floor is lime), so a category carrying real weight should point --category-11 at step 12 instead; the cost is a visibly darker label than the semantic pills beside it. Which step the system settles on is a question for the contrast pass, not one to answer per component.

The three --category-* names are not hub tokens — your theme declares them, the component reads them, which is the inverse of every other token in the kit. That's deliberate: the mechanism is the hub's, the vocabulary is your project's. The values you point them at should still be hub tokens, which is the whole argument above. A category with no ramp defined, or a typo in the name, falls back to the default pill rather than losing the declaration; and because the ramp arrives as one block, fill and text can't be set independently.

API

PropTypeDefaultDescription
label string required Required. Text shown inside the pill.
variant 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' 'default' Color intent — drives background, text, and border.
removable boolean true Shows a remove (×) button.
size 'xs' | 'sm' | 'md' | 'lg' 'md' Pill height and text size.
category string — Project vocabulary this pill labels — emits data-category for a spoke-defined color ramp. See Custom colors.
shape 'default' | 'round' 'default' Corner language. Composes with every variant and size.

Slots

PropTypeDefaultDescription
icon slot — Leading inline SVG, rendered left of the label.

Events

PropTypeDefaultDescription
removed CustomEvent — Bubbles when the remove button is clicked; the pill then removes itself from the DOM.

Accessibility

  • The remove button has aria-label="Remove"; the leading icon is aria-hidden.
  • Remove click stopPropagation()s so it won't trigger a click on a surrounding interactive container.
  • Color intent is reinforced by the label text — color is never the only signal.