BadgePill

Status pills, tags, and dismissible chips.

Live demo

01

Variant Γ— Tone matrix

soft
neutral info success warning danger brand
solid
neutral info success warning danger brand
outline
neutral info success warning danger brand

Sizes

Small Medium (default) Large

Status indicators

Active Pending Failed Draft Beta Reviewing

Dismissible tag picker

Frontend TypeScript Svelte Accessibility

In context Β· pull request row

#1247 feat: add BadgePill component to library ready frontend needs-review +12 / -3

Implementation

02
BadgePill.svelte
<script lang="ts">
  import BadgePill from '$lib/components/BadgePill.svelte';
</script>
​
<BadgePill label="Active" tone="success" dot />
<BadgePill label="Frontend" tone="info" dismissible />

BadgePill is one component, fifty-four looks: three variants (soft / solid / outline), six semantic tones (neutral / info / success / warning / danger / brand), and three sizes. Pass dot for a leading status indicator, dismissible for a Γ— button. Children can be a snippet for richer content (icons, bold sub-strings, counters). All colour combinations meet WCAG AA contrast and the dismiss button is fully keyboard accessible.

Logic explainer

03

What Does It Do? (Plain English)

BadgePill is a small rounded pill for status indicators, category tags, count badges, and removable filter chips. Three visual variants (solid / soft / outline) crossed with six semantic tones (neutral / info / success / warning / danger / brand) and three sizes give you fifty-four ready-made looks from one prop set. Optional leading dot for status indicators; optional trailing Γ— for tag-pickers.

Think of it as the swiss-army knife of UI labels: tiny, recognisable, and shaped to fit anywhere a single piece of metadata needs to live.

How It Works (Pseudo-Code)

props:
  label        = optional string
  tone         = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'brand'
  variant      = 'solid' | 'soft' | 'outline'
  size         = 'sm' | 'md' | 'lg'
  dot          = boolean
  dismissible  = boolean
  onDismiss    = optional callback
  children     = optional snippet (overrides label)

render <span class="badge-pill badge-{tone} badge-{variant} badge-{size}"
            data-tone={tone} data-variant={variant}>
  if dot:          <span .badge-dot aria-hidden />
  if children:     {render children()}
  else if label:   <span .badge-label>{label}</span>
  if dismissible:
    <button aria-label="Dismiss"
            onclick={(e) => { e.stopPropagation(); onDismiss?.(); }}>
      Γ—
    </button>
</span>

There is no internal state, no event lifecycle beyond the dismiss click. The component is pure CSS-driven presentation.

The Core Concept: Three Orthogonal Axes Compose 54 Looks

Most badge libraries ship dozens of pre-named variants (badge-success, badge-success-outline, badge-success-solid-large). BadgePill ships three orthogonal axes and lets the consumer combine them:

                        size
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚  sm    md    lg   β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
   tone β”‚  neutral      β”‚   β”Œβ”€β”  β”Œβ”€β”€β”  β”Œβ”€β”€β”€β”€β”
        β”‚  info         β”‚   β”‚ β”‚  β”‚  β”‚  β”‚    β”‚     Γ— variant ∈ {solid, soft, outline}
        β”‚  success      β”‚   β””β”€β”˜  β””β”€β”€β”˜  β””β”€β”€β”€β”€β”˜
        β”‚  warning      β”‚
        β”‚  danger       β”‚
        β”‚  brand        β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  3 variants Γ— 6 tones Γ— 3 sizes = 54 looks, one prop combination at a time

Each axis is independent:

  • Tone is the meaning β€” what does this pill represent?
  • Variant is the visual weight β€” how loud should it be?
  • Size is the physical scale β€” how much space does it occupy?

This composition lets you keep semantics and styling separate. The same tone="success" is applied to a quiet outline pill in a dense table and a high-contrast solid pill on a marketing page β€” without invented variant names.

Optional Status Dot

When dot={true}, a small leading circle is rendered using currentColor. Because the dot inherits the pill's foreground colour (which is set per-tone), it always matches the variant's palette automatically β€” no extra colour prop needed.

[●] Active     ← tone="success", dot=true β†’ dot is green
[●] Pending    ← tone="warning", dot=true β†’ dot is amber
[●] Failed     ← tone="danger",  dot=true β†’ dot is red

The dot is aria-hidden. Colour alone never carries meaning β€” the label says "Active" or "Pending" so colour-blind and non-visual users get the same information.

Dismiss Button Mechanics

When dismissible={true}, a real <button> is rendered as a child of the pill span. Clicking it:

  1. Fires event.stopPropagation() so a parent click handler (e.g. on the pill itself, if it's inside a clickable card) doesn't also fire.
  2. Calls onDismiss?.() β€” the component does not hide itself; it tells the parent to remove it from the data array.

The button is keyboard-focusable, has aria-label="Dismiss", and shows a focus ring tied to currentColor so it's visible against any tone's background.

<BadgePill label="Frontend" dismissible onDismiss={() => removeTag('frontend')} />

The parent maintains the source of truth (the tag array). The component is a leaf β€” it never owns the visibility decision.

Performance

A BadgePill is the cheapest possible component: a single <span> with two or three optional children. There are no observers, no transitions on initial render, no derived state to recompute. You can put thousands of pills on a page (a tag cloud, a kanban view, a faceted filter list) without performance impact.

The dismiss handler is a tiny inline function created per-pill; in heavy renders consider hoisting it to a stable callback in the parent if you're seeing reconciliation cost β€” but in practice this is never the bottleneck.

State Flow Diagram

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  parent renders  β”‚
              β”‚  <BadgePill ...> β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
                       β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚     VISIBLE      β”‚
              β”‚   (no internal   β”‚
              β”‚      state)      β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚ user clicks Γ—
                       β”‚ (only if dismissible)
                       β–Ό
              event.stopPropagation()
              onDismiss?.()
                       β”‚
                       β–Ό
              parent decides what to do
              (typically removes from array,
               causing the pill to unmount)

The component itself never changes state β€” appearance changes happen via prop updates from the parent, and removal happens via parent unmount.

Props Reference

Prop Type Default Description
label string undefined Text content. Use children snippet for richer content.
tone 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'brand' 'neutral' Semantic colour role.
variant 'solid' | 'soft' | 'outline' 'soft' Visual weight: filled / tinted / bordered.
size 'sm' | 'md' | 'lg' 'md' Pill physical size.
dot boolean false Show leading status dot, coloured to match the tone.
dismissible boolean false Render a trailing Γ— button.
onDismiss () => void undefined Fires when the Γ— button is clicked. Parent removes the pill.
children Snippet undefined Custom content snippet β€” overrides label.
class string '' Extra classes appended to the pill.

Edge Cases

Situation Behaviour
Both label and children are passed children wins; label is ignored. Lets callers add icons or rich content alongside text.
Neither is passed Pill renders empty β€” only the optional dot and Γ— show. Useful for status-dot-only indicators where the surrounding context provides the meaning.
dismissible is true but no onDismiss The Γ— renders; clicks fire stopPropagation but no removal happens. Pair them up.
Tone changes dynamically Colour palette updates immediately. The dot, label colour, and dismiss focus ring all follow because they all use currentColor or per-tone CSS.
Pill nested in a clickable parent The dismiss button calls event.stopPropagation() so clicking Γ— does not trigger the parent's click. Good.
User has prefers-reduced-motion: reduce No animations to disable β€” pills don't animate by default. The hover/focus transition on Γ— is removed cleanly.
Hundreds of pills in a tag cloud Performance is fine. No observers or watchers per pill; pure scoped CSS.
Long label that wraps The pill expands horizontally; if you don't want wrapping, set white-space: nowrap on the parent or constrain max-width on the pill via the class prop.

Dependencies

  • Svelte 5.x β€” $props, snippets.
  • Zero external runtime dependencies. Pure scoped CSS, no transitions on rest, no animations.

File Structure

src/lib/components/BadgePill.svelte         # component implementation
src/lib/components/BadgePill.md             # this file (rendered inside ComponentPageShell)
src/lib/components/BadgePill.test.ts        # vitest unit tests
src/routes/badgepill/+page.svelte           # demo page

API

04
PropTypeDefaultDescription
labelstringβ€”Pill text β€” required if you don't pass a children snippet.
tone"neutral" | "info" | "success" | "warning" | "danger" | "brand""neutral"Semantic colour family.
variant"soft" | "solid" | "outline""soft"Fill style.
size"sm" | "md" | "lg""md"Padding and font scale.
dotbooleanfalseRender a leading status dot in the tone colour.
dismissiblebooleanfalseRender a trailing Γ— button.
onDismiss() => voidβ€”Callback fired when Γ— is pressed.
childrenSnippetβ€”Custom inner content (overrides label).
classstring""Extra class names forwarded to the root.