Badge
A small status marker: a coloured dot on its own, or an outlined label. The colour sits in the dot and the outline, never in the text.
import { Badge } from 'rost/components/badge/Badge'
<Badge status="negative">Overdue</Badge>
<Badge status="positive" showIcon>Paid</Badge>
<Badge status="warning" size="small" aria-label="Due soon" />src/components/badge/Badge.tsx
import type { HTMLAttributes, ReactNode } from 'react'
import './badge.css'
import dotDefault from './dot-default.svg?url'
import dotNegative from './dot-negative.svg?url'
import dotPositive from './dot-positive.svg?url'
import dotWarning from './dot-warning.svg?url'
import iconDot from './icon-dot.svg?url'
export type BadgeStatus = 'default' | 'negative' | 'positive' | 'warning'
export type BadgeSize = 'small' | 'medium'
export interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
readonly status?: BadgeStatus
readonly size?: BadgeSize
/** Medium only: the dot in front of the label. Off by default, as in Figma. */
readonly showIcon?: boolean
/** Replaces that dot when `showIcon` is on. */
readonly icon?: ReactNode
/** The label. Medium only: Small has no text, so give it an `aria-label`. */
readonly children?: ReactNode
}
// Small is just the coloured dot, one picture per status, exactly as Figma exports it.
const DOT: Record<BadgeStatus, string> = { default: dotDefault, negative: dotNegative, positive: dotPositive, warning: dotWarning }
const b = 'rost-badge'
/**
* A status marker. Small is the dot on its own, Medium an outlined label.
* Built from contracts/badge.contract.yaml, following the Figma layers.
* Prop values are the contract's, lower case: `Negative` is `status="negative"`.
*/
export function Badge({ status = 'default', size = 'medium', showIcon = false, icon, children, className, 'aria-label': ariaLabel, ...rest }: BadgeProps) {
const cls = [b, `${b}--status-${status}`, `${b}--size-${size}`, className].filter(Boolean).join(' ')
if (size === 'small') {
// The dot is the whole badge, so the label becomes its alt text.
return (
<span className={cls} {...rest}>
<img src={DOT[status]} alt={ariaLabel ?? ''} />
</span>
)
}
return (
<span className={cls} aria-label={ariaLabel} {...rest}>
{showIcon && (
<span className={`${b}__icon`} aria-hidden="true">
{icon ?? (
<span className={`${b}__icons-dot`}>
<img src={iconDot} alt="" />
</span>
)}
</span>
)}
<span className={`${b}__label`}>{children}</span>
<span className={`${b}__outline`} />
</span>
)
}When to use
- Marking the status of something in a list, a card or a table row: Paid, Overdue, Draft.
Smallwhen there's no room for words, or the status is obvious from where it sits. Just the coloured dot.
When not to use
- Telling people something about the whole page. That's InlineBanner.
- Describing what something is about rather than its status. That's Tag.
- Anything clickable. Badge doesn't do hover or focus, Chip does.
Behaviour
Two sizes now. Small is only the dot, 24 by 24, coloured by status. Medium is the label with an outline, and status changes only the outline (non-interactive/background/<status>/light). The text stays non-interactive/foreground/tertiary whatever the status, on purpose: I keep badges quiet. There's no background fill either. My first Badge in akount.ing got exactly that wrong. The dot in front of the label is optional now (showIcon), and off unless you ask for it.
Accessibility
- Element: a
<span>. Nothing to click, nothing to focus. - Name: on
Medium, the label. OnSmallthere is no text, so passaria-labeland it becomes the dot's alt text. - Keyboard: none. It isn't interactive.
- Colour: the colour alone isn't enough. On
Mediumthe text says the status. OnSmallthere's no text, so give it anaria-label("Overdue") or put the status in words right next to it. - Dot:
aria-hidden="true"when there's a label next to it.
Content
One or two words. Sentence case, no punctuation.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| status | enum | Default · Negative · Positive · Warning | Default |
| size | enum | Medium · Small | Medium |
| label | slot | ||
| showIcon | boolean | false | |
| icon | slot |
Where it's used
- runceanu.com
src/rost/badge/Badge.tsx.rost-badge - akount.ing
src/rost/badge/Badge.tsx.rost-badge
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | display | inline-flex | layout |
| root | vertical-align | middle | layout |
| root | position | relative | layout |
| root | min-width | 24px | spacing/size/small |
Changes by variant
icon.align-items4
| status=Default, size=Medium | center | layout |
| status=Negative, size=Medium | center | layout |
| status=Positive, size=Medium | center | layout |
| status=Warning, size=Medium | center | layout |
icon.display4
| status=Default, size=Medium | flex | layout |
| status=Negative, size=Medium | flex | layout |
| status=Positive, size=Medium | flex | layout |
| status=Warning, size=Medium | flex | layout |
icon.flex-shrink4
| status=Default, size=Medium | 0 | layout |
| status=Negative, size=Medium | 0 | layout |
| status=Positive, size=Medium | 0 | layout |
| status=Warning, size=Medium | 0 | layout |
icon.padding-top4
| status=Default, size=Medium | 2px | spacing/gap/3xsmall |
| status=Negative, size=Medium | 2px | spacing/gap/3xsmall |
| status=Positive, size=Medium | 2px | spacing/gap/3xsmall |
| status=Warning, size=Medium | 2px | spacing/gap/3xsmall |
icon.position4
| status=Default, size=Medium | relative | layout |
| status=Negative, size=Medium | relative | layout |
| status=Positive, size=Medium | relative | layout |
| status=Warning, size=Medium | relative | layout |
icons-dot.flex-shrink4
| status=Default, size=Medium | 0 | layout |
| status=Negative, size=Medium | 0 | layout |
| status=Positive, size=Medium | 0 | layout |
| status=Warning, size=Medium | 0 | layout |
icons-dot.height4
| status=Default, size=Medium | 16px | spacing/size/2xsmall |
| status=Negative, size=Medium | 16px | spacing/size/2xsmall |
| status=Positive, size=Medium | 16px | spacing/size/2xsmall |
| status=Warning, size=Medium | 16px | spacing/size/2xsmall |
icons-dot.position4
| status=Default, size=Medium | relative | layout |
| status=Negative, size=Medium | relative | layout |
| status=Positive, size=Medium | relative | layout |
| status=Warning, size=Medium | relative | layout |
icons-dot.width4
| status=Default, size=Medium | 16px | spacing/size/2xsmall |
| status=Negative, size=Medium | 16px | spacing/size/2xsmall |
| status=Positive, size=Medium | 16px | spacing/size/2xsmall |
| status=Warning, size=Medium | 16px | spacing/size/2xsmall |
label.color4
| status=Default, size=Medium | #999 | non-interactive/foreground/tertiary |
| status=Negative, size=Medium | #999 | non-interactive/foreground/tertiary |
| status=Positive, size=Medium | #999 | non-interactive/foreground/tertiary |
| status=Warning, size=Medium | #999 | non-interactive/foreground/tertiary |
label.flex-shrink4
| status=Default, size=Medium | 0 | layout |
| status=Negative, size=Medium | 0 | layout |
| status=Positive, size=Medium | 0 | layout |
| status=Warning, size=Medium | 0 | layout |
label.font-family4
| status=Default, size=Medium | 'General Sans Variable' | font-family/base |
| status=Negative, size=Medium | 'General Sans Variable' | font-family/base |
| status=Positive, size=Medium | 'General Sans Variable' | font-family/base |
| status=Warning, size=Medium | 'General Sans Variable' | font-family/base |
label.font-size4
| status=Default, size=Medium | 12px | typography/label/font-size |
| status=Negative, size=Medium | 12px | typography/label/font-size |
| status=Positive, size=Medium | 12px | typography/label/font-size |
| status=Warning, size=Medium | 12px | typography/label/font-size |
label.font-weight4
| status=Default, size=Medium | 500 | typography/label/font-weight/medium |
| status=Negative, size=Medium | 500 | typography/label/font-weight/medium |
| status=Positive, size=Medium | 500 | typography/label/font-weight/medium |
| status=Warning, size=Medium | 500 | typography/label/font-weight/medium |
label.letter-spacing4
| status=Default, size=Medium | 0px | typography/label/letter-spacing |
| status=Negative, size=Medium | 0px | typography/label/letter-spacing |
| status=Positive, size=Medium | 0px | typography/label/letter-spacing |
| status=Warning, size=Medium | 0px | typography/label/letter-spacing |
label.line-height4
| status=Default, size=Medium | 14px | typography/label/line-height |
| status=Negative, size=Medium | 14px | typography/label/line-height |
| status=Positive, size=Medium | 14px | typography/label/line-height |
| status=Warning, size=Medium | 14px | typography/label/line-height |
label.position4
| status=Default, size=Medium | relative | layout |
| status=Negative, size=Medium | relative | layout |
| status=Positive, size=Medium | relative | layout |
| status=Warning, size=Medium | relative | layout |
label.text-align4
| status=Default, size=Medium | center | layout |
| status=Negative, size=Medium | center | layout |
| status=Positive, size=Medium | center | layout |
| status=Warning, size=Medium | center | layout |
label.white-space4
| status=Default, size=Medium | nowrap | layout |
| status=Negative, size=Medium | nowrap | layout |
| status=Positive, size=Medium | nowrap | layout |
| status=Warning, size=Medium | nowrap | layout |
outline.border-color4
| status=Default, size=Medium | #3a3a3a | non-interactive/foreground/quadruple |
| status=Negative, size=Medium | #48050c | non-interactive/background/negative/light |
| status=Positive, size=Medium | #004c19 | non-interactive/background/positive/light |
| status=Warning, size=Medium | #492604 | non-interactive/background/warning/light |
outline.border-radius4
| status=Default, size=Medium | 10000px | spacing/radius/full |
| status=Negative, size=Medium | 10000px | spacing/radius/full |
| status=Positive, size=Medium | 10000px | spacing/radius/full |
| status=Warning, size=Medium | 10000px | spacing/radius/full |
outline.border-sides4
| status=Default, size=Medium | all | layout |
| status=Negative, size=Medium | all | layout |
| status=Positive, size=Medium | all | layout |
| status=Warning, size=Medium | all | layout |
outline.inset4
| status=Default, size=Medium | 0 | layout |
| status=Negative, size=Medium | 0 | layout |
| status=Positive, size=Medium | 0 | layout |
| status=Warning, size=Medium | 0 | layout |
outline.opacity4
| status=Default, size=Medium | 1 | opacity/solid |
| status=Negative, size=Medium | 1 | opacity/solid |
| status=Positive, size=Medium | 1 | opacity/solid |
| status=Warning, size=Medium | 1 | opacity/solid |
outline.position4
| status=Default, size=Medium | absolute | layout |
| status=Negative, size=Medium | absolute | layout |
| status=Positive, size=Medium | absolute | layout |
| status=Warning, size=Medium | absolute | layout |
root.align-items4
| status=Default, size=Medium | center | layout |
| status=Negative, size=Medium | center | layout |
| status=Positive, size=Medium | center | layout |
| status=Warning, size=Medium | center | layout |
root.border-radius4
| status=Default, size=Medium | 10000px | spacing/radius/full |
| status=Negative, size=Medium | 10000px | spacing/radius/full |
| status=Positive, size=Medium | 10000px | spacing/radius/full |
| status=Warning, size=Medium | 10000px | spacing/radius/full |
root.gap4
| status=Default, size=Medium | 2px | spacing/gap/3xsmall |
| status=Negative, size=Medium | 2px | spacing/gap/3xsmall |
| status=Positive, size=Medium | 2px | spacing/gap/3xsmall |
| status=Warning, size=Medium | 2px | spacing/gap/3xsmall |
root.height8
| status=Default, size=Small | 24px | spacing/size/small |
| status=Negative, size=Small | 24px | spacing/size/small |
| status=Positive, size=Small | 24px | spacing/size/small |
| status=Warning, size=Small | 24px | spacing/size/small |
| status=Default, size=Medium | 24px | spacing/size/small |
| status=Negative, size=Medium | 24px | spacing/size/small |
| status=Positive, size=Medium | 24px | spacing/size/small |
| status=Warning, size=Medium | 24px | spacing/size/small |
root.justify-content4
| status=Default, size=Medium | center | layout |
| status=Negative, size=Medium | center | layout |
| status=Positive, size=Medium | center | layout |
| status=Warning, size=Medium | center | layout |
root.padding-bottom4
| status=Default, size=Medium | 2px | spacing/gap/3xsmall |
| status=Negative, size=Medium | 2px | spacing/gap/3xsmall |
| status=Positive, size=Medium | 2px | spacing/gap/3xsmall |
| status=Warning, size=Medium | 2px | spacing/gap/3xsmall |
root.padding-left4
| status=Default, size=Medium | 8px | spacing/gap/small |
| status=Negative, size=Medium | 8px | spacing/gap/small |
| status=Positive, size=Medium | 8px | spacing/gap/small |
| status=Warning, size=Medium | 8px | spacing/gap/small |
root.padding-right4
| status=Default, size=Medium | 8px | spacing/gap/small |
| status=Negative, size=Medium | 8px | spacing/gap/small |
| status=Positive, size=Medium | 8px | spacing/gap/small |
| status=Warning, size=Medium | 8px | spacing/gap/small |
root.padding-top4
| status=Default, size=Medium | 0px | spacing/gap/none |
| status=Negative, size=Medium | 0px | spacing/gap/none |
| status=Positive, size=Medium | 0px | spacing/gap/none |
| status=Warning, size=Medium | 0px | spacing/gap/none |
root.width4
| status=Default, size=Small | 24px | spacing/size/small |
| status=Negative, size=Small | 24px | spacing/size/small |
| status=Positive, size=Small | 24px | spacing/size/small |
| status=Warning, size=Small | 24px | spacing/size/small |