Forms and inputsBadge
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callBadge
Small labels for plans, environments and status, with a dot, an icon or a remove button. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add badgeReact
npx designme add badge --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Badge is a small label for a plan, an environment or a status: "Pro", "Production", "Failed". It comes in thirteen tones, neutral, the accent, success, warning and danger, plus eight tag colours, each drawn as text on a tint of its own colour. It has two sizes, an optional dot or icon in front of the label, and an optional remove button after it.
- When a name needs a short qualifier beside it, like a plan next to an organisation or an environment next to a project.
- When a list mixes states that people scan for, like Live, Degraded and Failed runs.
- When labels can be taken off one at a time, like the environments a view is filtered to.
- Not for actions: use a button, or a chip in Multi-select or Filter menu, for something people press. A badge that does something, a removable one or one written as a link or a button, shows a light hover so it reads as one.
tonepicks the colour. The accent tone is the accent's tint with the accent as text (--accent-bgand--accent-fg), so it follows an accent you set. Success, warning and danger use the state colours, and blue, violet, green, orange, red, yellow, pink and teal the tag palette, each with its own-bgtint.- Two sizes:
smis 20px tall with 11.5px text,md24px with 12.5px; Blueprint's capitals are a touch smaller, 10.5px and 11px. The label stays on one line; inside a.tspan (the React version always puts it there) a long one shortens with an ellipsis. dotputs a small dot in the badge's colour before the label, for a status;iconputs an icon there instead. In HTML,liveon the badge (<span class="bdg live" data-tone="success">) makes its dot pulse, for something running right now: a soft ring in the badge's colour grows and fades, on a loop.- A badge you can act on takes a light hover: a removable one, or one written as a
<button>or a link, gets a little more of its own colour in its tint. A plain label keeps still, so it never looks clickable. onRemoveadds a × button. In the HTML version it first sends a cancelablebadge-removeevent from the badge, then the badge shrinks away and the focus moves on to the next badge's remove button, or to Undo once none is left. On designme.dev, Undo puts the last one back where it was.- Soft draws the badge as a pill. Blueprint squares it off, sets the label in capitals with a little letter spacing, and edges it with a hairline in its own colour; the accent badge's hairline is the accent's mark, since Blueprint's lime tint is faint on white.
- Every tone's text measures at least 4.5:1 (WCAG 2) and APCA Lc 60 on the surface and on raised panels, in Soft and Blueprint, light and dark, and on a removable badge's hover tint too. On a hovered or highlighted menu row the label leans 15% toward the text colour, so it keeps Lc 60 on that tint.
- The label is plain text, so it is read as written; Blueprint's capitals are only CSS. The colour is never the only signal, since the label says what the tone means.
- The live pulse only moves transform and opacity, and it stops with reduced motion. The label still says the state, like Running, so the motion is never the only signal.
- The remove button is a real button named "Remove" and the label, like "Remove Staging", with the shared focus ring. On a touch screen it gets a 40px tap area without changing how it looks.
- On designme.dev a polite live region says what happened, "Removed Staging" or "Staging is back".
Usage
Import it and drive it from your own state.
Import
import { Badge } from '@/components/designme/badge/Badge';Example
The demo above as a React component, 43 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState, type ReactNode } from 'react';
import { Badge, type BadgeTone } from './Badge';
import { useStory } from '../lib/useScript';
const ENVS: { label: string; tone: BadgeTone }[] = [{ label: 'Production', tone: 'warning' }, { label: 'Staging', tone: 'blue' }, { label: 'Preview', tone: 'violet' }];
/* ---------- Badge: every tone, both sizes, a dot or an icon, and removable ones; Staging is taken off, then put back ---------- */
export default function BadgeDemo() {
const ref = useRef<HTMLDivElement>(null);
// the removable badges still showing, and the ones taken off, the latest last
const [shown, setShown] = useState(ENVS.map(e => e.label));
const [gone, setGone] = useState<string[]>([]);
const [said, setSaid] = useState('');
const remove = (label: string) => { setShown(s => s.filter(x => x !== label)); setGone(g => [...g, label]); setSaid(`Removed ${label}`); };
const undo = () => {
const last = gone[gone.length - 1]; if (!last) return;
setGone(g => g.slice(0, -1)); setShown(s => ENVS.map(e => e.label).filter(x => x === last || s.includes(x))); setSaid(`${last} is back`);
};
const undoRef = useRef(undo); undoRef.current = undo;
// the story stops at the visitor's first click or key, so it never removes a badge they meant to keep
useStory(ref, async ({ wait }) => {
await wait(2000); remove('Staging');
await wait(2000); undoRef.current();
});
const row = (label: string, children: ReactNode) => <div className="dm-bdx-row"><span className="dm-bdx-l dm-muted">{label}</span><div className="dm-bdx-set">{children}</div></div>;
return (
<div ref={ref} data-dm="" className="dm-bdx">
{row('Tones', <><Badge>Draft</Badge><Badge tone="accent">Beta</Badge><Badge tone="success">Live</Badge><Badge tone="warning">Degraded</Badge><Badge tone="danger">Failed</Badge></>)}
{row('Colours', <><Badge tone="blue">Search</Badge><Badge tone="violet">Reasoning</Badge><Badge tone="green">Data</Badge><Badge tone="orange">Billing</Badge>
<Badge tone="red">Urgent</Badge><Badge tone="yellow">Review</Badge><Badge tone="pink">Design</Badge><Badge tone="teal">Ops</Badge></>)}
{row('Sizes', <><Badge size="sm" tone="accent">Pro</Badge><Badge tone="accent">Pro</Badge></>)}
{row('Dot or icon', <><Badge tone="success" dot>Running</Badge><Badge dot>Idle</Badge><Badge icon="lock">Private</Badge><Badge tone="accent" icon="spark">Agent</Badge></>)}
{row('Removable', <>
{ENVS.filter(e => shown.includes(e.label)).map(e => <Badge key={e.label} tone={e.tone} onRemove={() => remove(e.label)}>{e.label}</Badge>)}
{gone.length > 0 && <button type="button" className="dm-pill dm-ghost dm-sm dm-bdx-undo" onClick={undo}>Undo</button>}
</>)}
<p className="dm-bdx-live" aria-live="polite">{said}</p>
</div>
);
}
Props
Everything Badge takes.
| Prop | Type | What it does |
|---|---|---|
childrenrequired | ReactNode | The label, like "Pro", "Production" or "Failed" |
tone | BadgeTone | Its colour, as text on its own tint. Neutral by default |
size | 'sm' | 'md' | 'sm' is 20px tall, 'md' 24px (the default) |
dot | boolean | A dot in the badge's colour before the label, for a status like Running |
icon | IconName | ReactNode | An icon before the label: a name from the icon set, or your own element |
onRemove | () => void | Adds a remove button after the label. Take the badge out of your list in it |
removeLabel | string | The remove button's name for screen readers. Defaults to "Remove" and the label, when the label is text |
className | string |
Code
The full source, as the CLI installs it.
Badge.tsxReact · 45 lines
'use client';
import { isValidElement, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { Icon, type IconName } from '../lib/icons';
import '../designme.css';
import './badge.css';
/** A badge's colour: neutral grey, the accent's tint, the three states, or one of the eight tag colours */
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'blue' | 'violet' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
export interface BadgeProps {
/** The label, like "Pro", "Production" or "Failed" */
children: ReactNode;
/** Its colour, as text on its own tint. Neutral by default */
tone?: BadgeTone;
/** 'sm' is 20px tall, 'md' 24px (the default) */
size?: 'sm' | 'md';
/** A dot in the badge's colour before the label, for a status like Running */
dot?: boolean;
/** An icon before the label: a name from the icon set, or your own element */
icon?: IconName | ReactNode;
/** Adds a remove button after the label. Take the badge out of your list in it */
onRemove?: () => void;
/** The remove button's name for screen readers. Defaults to "Remove" and the label, when the label is text */
removeLabel?: string;
className?: string;
}
/** A small label for a plan, an environment or a status, with an optional dot or icon and an optional remove button. */
export function Badge({ children, tone = 'neutral', size = 'md', dot = false, icon, onRemove, removeLabel, className }: BadgeProps) {
const text = typeof children === 'string' || typeof children === 'number' ? String(children) : '';
return (
<span data-dm="" className={cx('dm-bdg', size === 'sm' && 'dm-sm', className)} data-tone={tone === 'neutral' ? undefined : tone}>
{dot && <span className="dm-bdg-dot" aria-hidden="true" />}
{typeof icon === 'string' ? <Icon name={icon as IconName} size="xs" /> : isValidElement(icon) ? icon : null}
<span className="dm-t">{children}</span>
{onRemove && (
<button type="button" className="dm-bdg-x" aria-label={removeLabel ?? (text ? `Remove ${text}` : 'Remove')} onClick={onRemove}>
<Icon name="x" size="xs" />
</button>
)}
</span>
);
}
badge.cssReact · 51 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Badge ---------- */
/* a small label for a plan, an environment or a status. data-tone picks its colour (none is neutral): the accent's tint,
success, warning and danger, or one of the eight tag colours, each as text on its own tint. Two sizes (.sm), an optional
dot or icon in front, and an optional remove button after the label. Soft draws it as a pill; Blueprint squares it off,
sets it in capitals and gives it a hairline in its own colour */
.dm-bdg{--dm-bdg-bg:var(--dm-chip);--dm-bdg-fg:var(--dm-fg2);display:inline-flex;align-items:center;gap:var(--dm-s-1);max-width:100%;height:24px;padding:0 var(--dm-s-2);border-radius:var(--dm-r-pill);background:var(--dm-bdg-bg);color:var(--dm-bdg-fg);font-size:12.5px;font-weight:500;line-height:1;white-space:nowrap;vertical-align:middle}
.dm-bdg.dm-sm{height:20px;font-size:11.5px}
.dm-bdg>.dm-t{min-width:0;overflow:hidden;text-overflow:ellipsis}
.dm-bdg>.dm-i{flex:none}
.dm-bdg-dot{position:relative;width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
/* a live badge (.live: running, streaming, on air) pulses its dot: a soft ring in its own colour grows and fades, on a loop.
Only transform and opacity move, so it stays on the compositor; with reduced motion the dot stays still */
.dm-bdg.dm-live .dm-bdg-dot::after{content:"";position:absolute;inset:0;border-radius:inherit;background:currentColor;opacity:0;animation:dm-bdgpulse 1.8s cubic-bezier(.22,1,.36,1) infinite}
@keyframes dm-bdgpulse{0%{opacity:.5;transform:scale(1)}100%{opacity:0;transform:scale(2.8)}}
@media (prefers-reduced-motion:reduce){.dm-bdg.dm-live .dm-bdg-dot::after{animation:none}}
/* a badge you can act on, a removable one or one that is a button or a link, takes a light hover: a little more of its own
colour in its tint. Plain labels keep still, so they never look clickable */
.dm-bdg:is(:has(>.dm-bdg-x),button,a[href]){transition:background var(--dm-t-fast)}
.dm-bdg:is(button,a[href]){cursor:pointer;text-decoration:none}
@media (hover:hover) and (pointer:fine){.dm-bdg:is(:has(>.dm-bdg-x),button,a[href]):hover{background:color-mix(in srgb,var(--dm-bdg-fg) 7%,var(--dm-bdg-bg))}}
/* on the Badge card every badge answers the pointer: a hand, a light hover, and no text selection */
.dm-bdx .dm-bdg{cursor:pointer;user-select:none;-webkit-user-select:none;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-bdx .dm-bdg:hover{background:color-mix(in srgb,var(--dm-bdg-fg) 7%,var(--dm-bdg-bg))}}
.dm-bdg[data-tone="accent"]{--dm-bdg-bg:var(--dm-accent-bg);--dm-bdg-fg:var(--dm-accent-fg)}
.dm-bdg[data-tone="success"]{--dm-bdg-bg:var(--dm-ok-bg);--dm-bdg-fg:var(--dm-ok)}.dm-bdg[data-tone="warning"]{--dm-bdg-bg:var(--dm-warn-bg);--dm-bdg-fg:var(--dm-warn)}.dm-bdg[data-tone="danger"]{--dm-bdg-bg:var(--dm-bad-bg);--dm-bdg-fg:var(--dm-bad)}
.dm-bdg[data-tone="blue"]{--dm-bdg-bg:var(--dm-pb-bg);--dm-bdg-fg:var(--dm-pb)}.dm-bdg[data-tone="violet"]{--dm-bdg-bg:var(--dm-pl-bg);--dm-bdg-fg:var(--dm-pl)}.dm-bdg[data-tone="green"]{--dm-bdg-bg:var(--dm-pm-bg);--dm-bdg-fg:var(--dm-pm)}.dm-bdg[data-tone="orange"]{--dm-bdg-bg:var(--dm-pp-bg);--dm-bdg-fg:var(--dm-pp)}
.dm-bdg[data-tone="red"]{--dm-bdg-bg:var(--dm-pr-bg);--dm-bdg-fg:var(--dm-pr)}.dm-bdg[data-tone="yellow"]{--dm-bdg-bg:var(--dm-py-bg);--dm-bdg-fg:var(--dm-py)}.dm-bdg[data-tone="pink"]{--dm-bdg-bg:var(--dm-pk-bg);--dm-bdg-fg:var(--dm-pk)}.dm-bdg[data-tone="teal"]{--dm-bdg-bg:var(--dm-pt-bg);--dm-bdg-fg:var(--dm-pt)}
/* the remove button sits inside the badge's end; its hover is a little more of the badge's own colour */
.dm-bdg-x{display:grid;place-items:center;width:16px;height:16px;margin-right:calc(var(--dm-s-1) * -1);border-radius:50%;color:inherit;flex:none;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-bdg-x:hover{background:color-mix(in srgb,currentColor 16%,transparent)}}
.dm-bdg-x:focus-visible{outline-offset:1px;border-radius:50%}
.dm-bdg.dm-sm .dm-bdg-x{width:14px;height:14px}
/* on a hovered menu row's tint (a step lighter in dark mode) the label leans a little toward the text colour, so it keeps APCA 60 */
.dm-mi.dm-hl .dm-bdg{color:color-mix(in srgb,var(--dm-bdg-fg) 85%,var(--dm-fg))}
@media (hover:hover) and (pointer:fine){.dm-mi:hover .dm-bdg{color:color-mix(in srgb,var(--dm-bdg-fg) 85%,var(--dm-fg))}}
.dm-bdg.dm-go{animation:dm-bdgout .22s var(--dm-ease) forwards}
@keyframes dm-bdgout{to{opacity:0;transform:scale(.85)}}
:root:not([data-style="soft"]) .dm-bdg:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-bdg-fg) 14%,transparent);font-size:11px;letter-spacing:.03em;text-transform:uppercase}
:root:not([data-style="soft"]) .dm-bdg.dm-sm:not(:where([data-style="soft"],[data-style="soft"] *)){font-size:10.5px}
/* Blueprint's lime tint is faint on white, so an accent badge's edge is the accent's mark */
:root:not([data-style="soft"]) .dm-bdg[data-tone="accent"]:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-accent-mark) 50%,transparent)}
/* the card: each kind of badge in a row, named on the left */
.dm-bdx{width:100%;display:flex;flex-direction:column;gap:var(--dm-s-4);container:bdx/inline-size}
.dm-bdx-row{display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-bdx-l{width:76px;flex:none;font-size:12px}
.dm-bdx-set{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:var(--dm-s-2)}
.dm-pill.dm-bdx-undo{height:24px}
.dm-bdx-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@container bdx (max-width:360px){.dm-bdx-row{flex-direction:column;align-items:flex-start;gap:var(--dm-s-2)}.dm-bdx-l{width:auto}.dm-bdx-set{width:100%}}
badge.htmlHTML · 13 lines
<div class="card" data-c="badge">
<div class="stage-in">
<div class="bdx">
<div class="bdx-row"><span class="bdx-l muted">Tones</span><div class="bdx-set"><span class="bdg">Draft</span><span class="bdg" data-tone="accent">Beta</span><span class="bdg" data-tone="success">Live</span><span class="bdg" data-tone="warning">Degraded</span><span class="bdg" data-tone="danger">Failed</span></div></div>
<div class="bdx-row"><span class="bdx-l muted">Colours</span><div class="bdx-set"><span class="bdg" data-tone="blue">Search</span><span class="bdg" data-tone="violet">Reasoning</span><span class="bdg" data-tone="green">Data</span><span class="bdg" data-tone="orange">Billing</span><span class="bdg" data-tone="red">Urgent</span><span class="bdg" data-tone="yellow">Review</span><span class="bdg" data-tone="pink">Design</span><span class="bdg" data-tone="teal">Ops</span></div></div>
<div class="bdx-row"><span class="bdx-l muted">Sizes</span><div class="bdx-set"><span class="bdg sm" data-tone="accent">Pro</span><span class="bdg" data-tone="accent">Pro</span></div></div>
<div class="bdx-row"><span class="bdx-l muted">Dot or icon</span><div class="bdx-set"><span class="bdg live" data-tone="success"><span class="bdg-dot"></span>Running</span><span class="bdg"><span class="bdg-dot"></span>Idle</span><span class="bdg"><svg class="i xs"><use href="#i-lock"/></svg>Private</span><span class="bdg" data-tone="accent"><svg class="i xs"><use href="#i-spark"/></svg>Agent</span></div></div>
<div class="bdx-row"><span class="bdx-l muted">Removable</span><div class="bdx-set bdx-rm"><span class="bdg" data-tone="warning"><span class="t">Production</span><button class="bdg-x" aria-label="Remove Production"><svg class="i xs"><use href="#i-x"/></svg></button></span><span class="bdg" data-tone="blue"><span class="t">Staging</span><button class="bdg-x" aria-label="Remove Staging"><svg class="i xs"><use href="#i-x"/></svg></button></span><span class="bdg" data-tone="violet"><span class="t">Preview</span><button class="bdg-x" aria-label="Remove Preview"><svg class="i xs"><use href="#i-x"/></svg></button></span><button class="pill ghost sm bdx-undo" hidden>Undo</button></div></div>
<p class="bdx-live" aria-live="polite"></p>
</div>
</div>
</div>
badge.cssHTML · 50 lines
/* ---------- Badge ---------- */
/* a small label for a plan, an environment or a status. data-tone picks its colour (none is neutral): the accent's tint,
success, warning and danger, or one of the eight tag colours, each as text on its own tint. Two sizes (.sm), an optional
dot or icon in front, and an optional remove button after the label. Soft draws it as a pill; Blueprint squares it off,
sets it in capitals and gives it a hairline in its own colour */
.bdg{--bdg-bg:var(--chip);--bdg-fg:var(--fg2);display:inline-flex;align-items:center;gap:var(--s-1);max-width:100%;height:24px;padding:0 var(--s-2);border-radius:var(--r-pill);background:var(--bdg-bg);color:var(--bdg-fg);font-size:12.5px;font-weight:500;line-height:1;white-space:nowrap;vertical-align:middle}
.bdg.sm{height:20px;font-size:11.5px}
.bdg>.t{min-width:0;overflow:hidden;text-overflow:ellipsis}
.bdg>.i{flex:none}
.bdg-dot{position:relative;width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
/* a live badge (.live: running, streaming, on air) pulses its dot: a soft ring in its own colour grows and fades, on a loop.
Only transform and opacity move, so it stays on the compositor; with reduced motion the dot stays still */
.bdg.live .bdg-dot::after{content:"";position:absolute;inset:0;border-radius:inherit;background:currentColor;opacity:0;animation:bdgpulse 1.8s cubic-bezier(.22,1,.36,1) infinite}
@keyframes bdgpulse{0%{opacity:.5;transform:scale(1)}100%{opacity:0;transform:scale(2.8)}}
@media (prefers-reduced-motion:reduce){.bdg.live .bdg-dot::after{animation:none}}
/* a badge you can act on, a removable one or one that is a button or a link, takes a light hover: a little more of its own
colour in its tint. Plain labels keep still, so they never look clickable */
.bdg:is(:has(>.bdg-x),button,a[href]){transition:background var(--t-fast)}
.bdg:is(button,a[href]){cursor:pointer;text-decoration:none}
@media (hover:hover) and (pointer:fine){.bdg:is(:has(>.bdg-x),button,a[href]):hover{background:color-mix(in srgb,var(--bdg-fg) 7%,var(--bdg-bg))}}
/* on the Badge card every badge answers the pointer: a hand, a light hover, and no text selection */
.bdx .bdg{cursor:pointer;user-select:none;-webkit-user-select:none;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.bdx .bdg:hover{background:color-mix(in srgb,var(--bdg-fg) 7%,var(--bdg-bg))}}
.bdg[data-tone="accent"]{--bdg-bg:var(--accent-bg);--bdg-fg:var(--accent-fg)}
.bdg[data-tone="success"]{--bdg-bg:var(--ok-bg);--bdg-fg:var(--ok)}.bdg[data-tone="warning"]{--bdg-bg:var(--warn-bg);--bdg-fg:var(--warn)}.bdg[data-tone="danger"]{--bdg-bg:var(--bad-bg);--bdg-fg:var(--bad)}
.bdg[data-tone="blue"]{--bdg-bg:var(--pb-bg);--bdg-fg:var(--pb)}.bdg[data-tone="violet"]{--bdg-bg:var(--pl-bg);--bdg-fg:var(--pl)}.bdg[data-tone="green"]{--bdg-bg:var(--pm-bg);--bdg-fg:var(--pm)}.bdg[data-tone="orange"]{--bdg-bg:var(--pp-bg);--bdg-fg:var(--pp)}
.bdg[data-tone="red"]{--bdg-bg:var(--pr-bg);--bdg-fg:var(--pr)}.bdg[data-tone="yellow"]{--bdg-bg:var(--py-bg);--bdg-fg:var(--py)}.bdg[data-tone="pink"]{--bdg-bg:var(--pk-bg);--bdg-fg:var(--pk)}.bdg[data-tone="teal"]{--bdg-bg:var(--pt-bg);--bdg-fg:var(--pt)}
/* the remove button sits inside the badge's end; its hover is a little more of the badge's own colour */
.bdg-x{display:grid;place-items:center;width:16px;height:16px;margin-right:calc(var(--s-1) * -1);border-radius:50%;color:inherit;flex:none;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.bdg-x:hover{background:color-mix(in srgb,currentColor 16%,transparent)}}
.bdg-x:focus-visible{outline-offset:1px;border-radius:50%}
.bdg.sm .bdg-x{width:14px;height:14px}
/* on a hovered menu row's tint (a step lighter in dark mode) the label leans a little toward the text colour, so it keeps APCA 60 */
.mi.hl .bdg{color:color-mix(in srgb,var(--bdg-fg) 85%,var(--fg))}
@media (hover:hover) and (pointer:fine){.mi:hover .bdg{color:color-mix(in srgb,var(--bdg-fg) 85%,var(--fg))}}
.bdg.go{animation:bdgout .22s var(--ease) forwards}
@keyframes bdgout{to{opacity:0;transform:scale(.85)}}
:root:not([data-style="soft"]) .bdg:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--bdg-fg) 14%,transparent);font-size:11px;letter-spacing:.03em;text-transform:uppercase}
:root:not([data-style="soft"]) .bdg.sm:not(:where([data-style="soft"],[data-style="soft"] *)){font-size:10.5px}
/* Blueprint's lime tint is faint on white, so an accent badge's edge is the accent's mark */
:root:not([data-style="soft"]) .bdg[data-tone="accent"]:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent-mark) 50%,transparent)}
/* the card: each kind of badge in a row, named on the left */
.bdx{width:100%;display:flex;flex-direction:column;gap:var(--s-4);container:bdx/inline-size}
.bdx-row{display:flex;align-items:center;gap:var(--s-2)}
.bdx-l{width:76px;flex:none;font-size:12px}
.bdx-set{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2)}
.pill.bdx-undo{height:24px}
.bdx-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@container bdx (max-width:360px){.bdx-row{flex-direction:column;align-items:flex-start;gap:var(--s-2)}.bdx-l{width:auto}.bdx-set{width:100%}}
badge.jsHTML · 40 lines
DM.def('badge', (r, c) => {
r.innerHTML = r.innerHTML.replace('{lock}', I('lock','xs')).replace('{spark}', I('spark','xs')).replace(/\{x\}/g, I('x','xs'));
const set = $('.bdx-rm', r), undo = $('.bdx-undo', r), live = $('.bdx-live', r);
// the rows sit centred in their stage whatever the badges wrap to, and glide back to the middle as a remove or Undo
// changes a row's height
const box = $('.bdx', r); centre(c, box, () => box.offsetHeight);
const label = b => $('.t', b).textContent;
// each removable badge keeps its place in the row, so Undo puts it back where it was
$$('.bdg', set).forEach((b, i) => { b.dataset.i = i; });
// the badges taken off, the latest last
const gone = [];
// a remove button first sends a cancelable badge-remove event from its badge, so your code can keep it or act on it
function remove(b){
if (b.classList.contains('go')) return;
if (!b.dispatchEvent(new CustomEvent('badge-remove', { bubbles:true, cancelable:true, detail:{ label:label(b) } }))) return;
const had = b.contains(document.activeElement), all = $$('.bdg:not(.go)', set), i = all.indexOf(b), next = all[i + 1] || all[i - 1];
gone.push(b); live.textContent = `Removed ${label(b)}`;
// the focus goes on to the next badge's remove button, or to Undo once none is left
const done = () => { b.remove(); b.classList.remove('go'); undo.hidden = false; if (had) ((next && next.isConnected && $('.bdg-x', next)) || undo).focus({ preventScroll:true }); };
if (REDUCE) done(); else { b.classList.add('go'); c.later(220, done); }
}
function restore(){
const b = gone.pop(); if (!b) return;
const had = undo === document.activeElement, after = $$('.bdg', set).find(x => +x.dataset.i > +b.dataset.i);
set.insertBefore(b, after || undo);
b.classList.remove('pop-in'); void b.offsetWidth; b.classList.add('pop-in');
undo.hidden = !gone.length; live.textContent = `${label(b)} is back`;
if (had && undo.hidden) $('.bdg-x', b).focus({ preventScroll:true });
}
set.addEventListener('click', e => { const x = e.target.closest('.bdg-x'); if (x) remove(x.closest('.bdg')); });
undo.onclick = restore;
// the preview's story: take Staging off, then put it back with Undo
c.story(async s => {
await s.beat(4);
const b = $$('.bdg', set).find(x => label(x) === 'Staging'); if (!b) return;
await s.tap($('.bdg-x', b)); s.away(); await s.beat(4);
await s.tap(undo); s.away(); await s.beat(4);
});
});
Questions
What people ask about it.
- How do I keep a badge when someone presses its ×?
- In HTML, listen for
badge-removeand callpreventDefault(); the badge stays. In React,onRemoveis yours: take the badge out of your list there, or don't. - Can I use my own colours?
- Each tone sets two variables on the badge,
--bdg-bgand--bdg-fg(--dm-bdg-bgand--dm-bdg-fgin React). Set them in your own class for a new colour, and check the text against its tint. - Why is the accent badge a tint and not a solid fill?
- So it reads as a highlight, not a button. A solid accent fill is kept for the primary action.
- Does the Context switcher use it?
- It draws the same small badge, in the same tones, with its own copy of the styles, so each component installs on its own.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.