MobTranslate styleguide

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.

KindVisible formAccessible meaning
LabelShort explicit textRead in context with the subject.
CountActual number or documented capReal count and what is counted.
ZeroExplicit empty state when usefulNo invented activity or misleading persistent dot.
DotSmall decorative indicator plus text alternativeName the new information in the owning control or nearby text.

Anatomy and dimensions

PropertyActual CSS
Type--text-xs, weight 600, line-height 1.5
Padding2px block / 10px inline
ShapePill default; rounded uses --radius-md
Outline1.5px --color-border; text still conveys the label
Long contentWraps within max-width 100%; do not use it for paragraphs
InteractionRead-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 pointLocal decision
Short countsThe 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 collisionsKeep 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 scopeHide 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 textReference 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 informationExpose 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

default exampleprimary examplesecondary exampledestructive exampleerror exampleoutline examplesuccess examplewarning example

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

tsx
'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

PropType / defaultContract
variantdefault | primary | secondary | destructive | error | outline | success | warning; defaultEach exported variant now has actual styling.
shapepill | rounded; pillPreserves the previous pill default; rounded is a real alternative.
childrenReactNodeLabel/count content; screen-reader text may carry the unshortened value.
className / style / native span props / refHTMLSpanElementNo 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.