GitHub

Pill

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

Stable .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.

Theming surface

Every public token this component reads, extracted from its source at build time, split by blast radius: the hooks that belong to this component, then the shared and system tokens it consumes alongside everything else. The Resolves to column walks each token's real lineage — component → semantic → primitive → raw value — so you can see exactly where re-pointing it lands. A spoke re-skins by overriding these in its theme-<slug>.css; component internals are never edited. No hook for what you need? File it with /request-lego.

Type roles it names

This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.

RoleRe-pointReach
.typography-microcopy-2xs
  • --typography-microcopy-2xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-2xs-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-microcopy-2xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-2xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-2xs-line-height 1 via --line-height-none
+3 more components esa-button, esa-button-toggle, esa-range-slider
.typography-microcopy-md
  • --typography-microcopy-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-md-line-height 1 via --line-height-none
+4 more components esa-button, esa-button-toggle, esa-range-slider, esa-sidebar-nav
.typography-microcopy-sm
  • --typography-microcopy-sm-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-microcopy-sm-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-sm-line-height 1 via --line-height-none
+3 more components esa-filter-clear-button, esa-filter-dropdown, esa-sidebar-nav
.typography-microcopy-xs
  • --typography-microcopy-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-line-height 1 via --line-height-none
+6 more components esa-app-shell, esa-button, esa-button-toggle, esa-filter-pills, esa-kbd, esa-range-slider

Wired to this component

No other component reads these — re-point them and esa-pill is the only thing that moves.

Token Tier Resolves to
--category-11 component #202020 via --color-content-default → --color-gray-12
--category-2 component #f0f0f0 via --color-background-elevation-sunken → --color-gray-3
--category-6 component #d9d9d9 via --color-border-default-subtle → --color-gray-6

Shared tokens it reads

Owned by a family or by the semantic layer. Still overridable — but scope the override (esa-foo { --form-border-color: … }) unless you mean to move everything that reads it. Reach says what else that is.

Token Reach Resolves to
--focus-ring-color component focus ring group · +34 more #3e9b4f via --color-border-default-focus → --color-grass-10
--focus-ring-offset component focus ring group · +31 more 2px
--focus-ring-width component focus ring group · +35 more 2px via --border-width-focus → --border-width-200
--border-width-default semantic every component 1px via --border-width-100
--color-background-brand-subtle semantic every component #fbfefb via --color-grass-1
--color-background-elevation-sunken semantic every component #f0f0f0 via --color-gray-3
--color-background-overlay-heavy-hover semantic every component rgba(0, 0, 0, 0.1) via --color-black-a-2
--color-background-utility-danger-subtle semantic every component #fffcfc via --color-red-1
--color-background-utility-info-subtle semantic every component #fbfdff via --color-blue-1
--color-background-utility-success-subtle semantic every component #fbfefc via --color-green-1
--color-background-utility-warning-subtle semantic every component #fefdfb via --color-yellow-1
--color-border-brand semantic every component #b2ddb5 via --color-grass-6
--color-border-default-subtle semantic every component #d9d9d9 via --color-gray-6
--color-border-utility-danger semantic every component #fdbdbe via --color-red-6
--color-border-utility-info semantic every component #acd8fc via --color-blue-6
--color-border-utility-success semantic every component #adddc0 via --color-green-6
--color-border-utility-warning semantic every component #f3d673 via --color-yellow-6
--color-content-brand semantic every component #2a7e3b via --color-grass-11
--color-content-default semantic every component #202020 via --color-gray-12
--color-content-utility-danger semantic every component #ce2c31 via --color-red-11
--color-content-utility-info semantic every component #0d74ce via --color-blue-11
--color-content-utility-success semantic every component #218358 via --color-green-11
--color-content-utility-warning semantic every component #ab6400 via --color-yellow-11
--radius-chip semantic every component 0.25rem via --radius-100
--radius-pill semantic every component 9999px via --radius-full
--transition-fast semantic every component 150ms via --duration-150
--spacing-100 primitive every component 0.25rem
--spacing-150 primitive every component 0.375rem
--spacing-200 primitive every component 0.5rem
--spacing-300 primitive every component 0.75rem