Badge
Attach a concise read-only label, count or status to its context without turning color into evidence.
Labels, counts and dot patterns
Use a badge for short metadata that remains meaningful beside its subject. A badge is a span, not an action, filter, toggle or permission record. Use Chip, a labelled control or a link when the user needs to act.
For compact counts, expose the real number when the visible form caps it at 99+. For a dot-only notification pattern, supply meaningful text outside the decorative dot. A dot cannot say whether content was reviewed, approved or permitted.
| Kind | Visible form | Accessible meaning |
|---|---|---|
| Label | Short explicit text | Read in context with the subject. |
| Count | Actual number or documented cap | Real count and what is counted. |
| Zero | Explicit empty state when useful | No invented activity or misleading persistent dot. |
| Dot | Small decorative indicator plus text alternative | Name the new information in the owning control or nearby text. |
Anatomy and dimensions
| Property | Actual CSS |
|---|---|
| Type | --text-xs, weight 600, line-height 1.5 |
| Padding | 2px block / 10px inline |
| Shape | Pill default; rounded uses --radius-md |
| Outline | 1.5px --color-border; text still conveys the label |
| Long content | Wraps within max-width 100%; do not use it for paragraphs |
| Interaction | Read-only span; no 44px action-target claim |
Material notification badges and local metadata
Material’s small and large badges qualify a navigation destination: a dot for new information or a short count/status, with at most four visible characters including a plus. Its native reference uses a 6dp small circle, 16dp one-digit large badge and 16×34dp maximum-count container, with error/on-error color roles. These measurements are not converted into the local Badge span, which also serves general metadata labels and has the actual padding/type/shape contract above.
The local inbox example places a decorative 6 CSS px dot at the upper trailing corner of a 24 CSS px icon box, with a smaller glyph leaving that corner free. Logical positioning mirrors it in RTL. Its error color denotes a notification, not urgent announcement priority. The owning button supplies one name including the actual unread count; the dot adds no duplicate speech or Tab stop. Opening the inbox clears unread state while retaining its actual local messages. New messages while it is open are already read.
| Reference point | Local decision |
|---|---|
| Short counts | The separate count specimen caps visually at 99+ (three characters) and exposes the true number. Zero removes the badge and gives explicit empty text. The API does not format, translate or cap counts automatically. |
| Anchoring and collisions | Keep a notification indicator attached to its owner, inside a reserved icon corner; never cover the glyph, following text, a touch target or screen edge. If a count cannot fit, use a dot plus the full owning-control name, or put a count beside the label. |
| Selection and scope | Hide unread indicators after the related destination is opened. Do not clear unrelated task counts or erase messages merely because a badge is hidden. A metadata badge is not necessarily unread and should not disappear by the same policy. |
| Color and text | Reference notification badges use their default error/on-error roles and require 3:1 non-text contrast. Local readable badge text still follows the site’s 4.5:1 normal-text requirement; check the actual surface, not the name of a variant. |
| Dynamic information | Expose the number and what it counts after the destination name. For a non-counting dot, supply New notification or similarly specific context. Avoid announcing every arrival twice through both a badge live region and its owner. |
Working variants, shape and count
Local tasks: 1 task
The compact count caps at 99+ visually; its text alternative retains the actual count. This badge is read-only, not a filter or a review credential.
Local inbox and unread ownership
No local notifications yet.
'use client';
import { useRef, useState } from 'react';
import { Alert, Badge, Button, VisuallyHidden, type AlertProps, type BadgeProps } from '@mobtranslate/ui';
const alertVariants: NonNullable<AlertProps['variant']>[] = ['default', 'info', 'success', 'warning', 'error', 'destructive'];
const badgeVariants: NonNullable<BadgeProps['variant']>[] = ['default', 'primary', 'secondary', 'destructive', 'error', 'outline', 'success', 'warning'];
export function AlertCommunicationExample() {
const [visible, setVisible] = useState(false);
const restore = useRef<HTMLElement>(null);
const [result, setResult] = useState('');
const [headingLevel, setHeadingLevel] = useState<AlertProps['headingLevel']>();
return <div data-alert-lab className="space-y-4">
<p>These specimens describe local examples. Their color does not establish review, consent or publication.</p>
<label className="block" htmlFor="alert-title-structure">Alert title structure</label>
<select id="alert-title-structure" className="mt-input" value={headingLevel ?? 'paragraph'} onChange={event => setHeadingLevel(event.target.value === 'paragraph' ? undefined : 3)}><option value="paragraph">Paragraph lead</option><option value="3">Level 3 heading under this section</option></select>
<div className="space-y-3">{alertVariants.map(variant => <Alert key={variant} variant={variant} role="note" title={`${variant} example`} headingLevel={headingLevel} description="Keep the source and the next step understandable." data-tone={variant} />)}</div>
<Button ref={restore} type="button" onClick={() => { setVisible(true); setResult(''); }}>Show recoverable alert</Button>
{visible && <Alert role="status" variant="warning" title="Local task paused" description="Your example input is retained." dismissible dismissLabel="Dismiss local task message"
onDismiss={() => { setVisible(false); setResult('Message dismissed; the local task remains paused.'); restore.current?.focus(); }}
action={<Button type="button" variant="secondary" onClick={() => { setVisible(false); setResult('Local task resumed. No request was sent.'); restore.current?.focus(); }}>Resume local task</Button>}>
<p className="text-sm">Additional child content stays visible beside shorthand title and description.</p>
</Alert>}
<p role="status">{result}</p>
</div>;
}
export function BadgeCommunicationExample() {
const [count, setCount] = useState(1);
const [messages, setMessages] = useState<{id:number;subject:string}[]>([]);
const [unread, setUnread] = useState(0);
const [inboxOpen, setInboxOpen] = useState(false);
const inboxTrigger=useRef<HTMLElement>(null);
const messageId=useRef(0);
const [shape, setShape] = useState<NonNullable<BadgeProps['shape']>>('pill');
return <div data-badge-lab className="space-y-4">
<label className="block">Badge shape <select value={shape} onChange={event => setShape(event.target.value as NonNullable<BadgeProps['shape']>)} className="mt-input"><option value="pill">Pill</option><option value="rounded">Rounded</option></select></label>
<div className="flex flex-wrap gap-3">{badgeVariants.map(variant => <Badge key={variant} variant={variant} shape={shape} data-tone={variant}>{variant} example</Badge>)}</div>
<label className="block">Local task count <input type="range" min="0" max="120" value={count} onChange={event => setCount(Number(event.target.value))} className="block w-full" /></label>
<p role="status" aria-atomic="true">{count === 0 ? 'No local tasks.' : <>Local tasks: <Badge data-count-badge shape={shape}><span aria-hidden="true">{count > 99 ? '99+' : count}</span><VisuallyHidden>{count} {count === 1 ? 'task' : 'tasks'}</VisuallyHidden></Badge></>}</p>
<p className="text-sm">The compact count caps at 99+ visually; its text alternative retains the actual count. This badge is read-only, not a filter or a review credential.</p>
<h3>Local inbox and unread ownership</h3>
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline" onClick={()=>{const id=++messageId.current;setMessages(items=>[...items,{id,subject:`Local example notification ${id}`}]);setUnread(current=>inboxOpen?0:current+1);}}>Add local inbox notification</Button>
<Button ref={inboxTrigger} data-inbox-trigger type="button" variant="outline" aria-label={`Open local inbox, ${unread?`${unread} unread ${unread===1?'notification':'notifications'}`:'no unread notifications'}`} aria-expanded={inboxOpen} aria-controls="badge-local-inbox" onClick={()=>{setInboxOpen(true);setUnread(0);}}>
<span aria-hidden="true" data-inbox-icon style={{position:'relative',display:'inline-block',width:24,height:24,flexShrink:0}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{position:'absolute',insetInlineStart:0,insetBlockEnd:0}}><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 6 9 7 9-7"/></svg>
{unread>0&&<span data-inbox-dot style={{position:'absolute',insetBlockStart:0,insetInlineEnd:0,width:6,height:6,borderRadius:'50%',background:'var(--color-error)'}}/>}
</span>Open local inbox
</Button>
</div>
<section id="badge-local-inbox" aria-label="Local inbox" hidden={!inboxOpen}>
<p>{messages.length?`${messages.length} retained local notifications. Opening marks them read; it does not delete them.`:'No local notifications yet.'}</p>
<ul>{messages.map(message=><li key={message.id}>{message.subject}</li>)}</ul>
<Button type="button" variant="outline" onClick={()=>{setInboxOpen(false);inboxTrigger.current?.focus();}}>Close local inbox</Button>
</section>
</div>;
}
API reference
| Prop | Type / default | Contract |
|---|---|---|
| variant | default | primary | secondary | destructive | error | outline | success | warning; default | Each exported variant now has actual styling. |
| shape | pill | rounded; pill | Preserves the previous pill default; rounded is a real alternative. |
| children | ReactNode | Label/count content; screen-reader text may carry the unshortened value. |
| className / style / native span props / ref | HTMLSpanElement | No built-in interactive state or count formatting. |
Behavior and accessibility
- Keep the badge beside the subject it qualifies. Avoid a detached color legend as the only explanation.
- An interactive owner should have one coherent name, including a count where appropriate. Do not make the badge an extra Tab stop.
- Use a polite, concise count announcement only when the change matters; do not repeatedly announce an entire results list.
- Separate actual review/publication/source metadata. A green synthetic example label is not qualified language review.
Failure cases
- A decorative dot without an accessible context.
- 99+ becoming the only count available to assistive technology.
- A clickable span with no native keyboard behavior.
- Pill and rounded props producing the same CSS, or an exported error variant with no rule.
Verification and release scope
- Operate the checked example with keyboard and pointer; inspect the actual name, role, relationships and focus destination.
- Check both themes, narrow reflow, long labels, reduced motion and forced colors. Text contrast and meaningful graphic contrast are separate checks.
- Test the relevant waiting, completion, failure, cancellation and recovery paths. A local simulation is not a verified backend result.
- Use a named screen-reader/browser pair to verify spoken output. DOM attributes and automated passes do not prove the announcement.
- Retain source, review and permission evidence when applying these controls to a real language workflow. Color and motion cannot establish authority.