UIDex

Badge

الشارة

role="status" / badgeContent

also callednotification badge، count badge، dot badge، status badge، unread count، notification dot، بادج، عدّاد الإشعارات، النقطة الحمراء، شارة العدّ، شارة الحالة، عدّاد غير المقروء

A badge is a small mark attached to the corner of another element, and it reports one thing about that element: how many items are waiting inside it, or that something in it has not been seen. It is always dependent and never read on its own, so a badge over a mail icon describes the mail, not itself. A badge is not a chip: a chip is a control that sits in the tab order and changes page state when you press it, while a badge takes neither focus nor a press, and what gets pressed is the element underneath. It is not a tag either: a tag sits in the flow of text and classifies an article or a product whether or not anything changed, whereas a badge is anchored to the edge of one specific element and has no meaning without it. It is also not the presence dot on an avatar, which reports a standing state such as "online", while a badge dot reports something unread that goes away once it is read. What separates it from all three is ownership: a badge owns no action and no touch target of its own, and its wording belongs inside the anchor's accessible name. Badges come in two forms: a count badge carrying a number with an overflow cap, usually written 99+, and a dot badge with no number that says only that something is new.

If you called it…

"the little red circle on the app icon""the number sitting on the bell""the dot that shows up when something is new""the counter that is stuck on 99+""the unread count on the mail tab"

Live specimen

Interact with the demo. Every part is real and numbered.

الاسم المقروء للجرس: «الإشعارات، 3 إشعارات غير مقروءة»الإشعارات، 3 إشعارات غير مقروءة. الرسائل، 128 رسالة غير مقروءة. الحساب، يوجد نشاط جديد.الشارة نفسها ليست زرّاً، والضغط عليها يمرّ إلى الأيقونة تحتها.
RTLالشارة تنتقل إلى ركن النهاية المنطقية، والرقم معزول بـ dir="ltr"

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a badge that attaches to an icon or a tab. Make the anchor a real button carrying position: relative with no overflow: hidden, and make the badge an absolutely positioned span with pointer-events: none, never a button, a link, or anything with a tabindex. Pin it with negative inset-block-start and inset-inline-end so it moves to the logical inline-end corner by itself, never top or right, and if you need a transform then sign the offset with a --dir variable set to -1 under [dir="rtl"], because transform has no logical form. Size the pill with min-inline-size plus padding-inline instead of a fixed width, and add font-variant-numeric: tabular-nums. Cap the count in the display only, keep the true number in state and in the spoken text, and render no pill at all at 0. Wrap the digits and the plus sign together in one element carrying dir="ltr" so the sign does not move to the other side inside an Arabic page, and derive the digit shapes from Intl.NumberFormat using one numbering system for the whole product. Ship both forms, a counter with a number and a dot without one, and give the dot visually hidden text, since a bare span takes no aria-label. Write the accessible name as a phrase that includes the noun in aria-label on the anchor, and hide the whole pill with aria-hidden="true" so the count is not spoken twice. Keep one role="status" region in the DOM from first paint and rewrite its text as the count moves, and never let colour alone carry the difference between "new" and "error".

How it behaves right-to-left

This section is ours alone.

Which corner it takes

mirrors

The badge belongs at the logical inline end of the anchor's top edge: top right in English, top left in Arabic. Two negative logical insets do the whole job, inset-block-start: -6px with inset-inline-end: -6px, and the side moves on its own with no RTL rule. The popular recipe of inset-inline-end: 0 plus transform: translate(50%, -50%) mirrors only half of itself, because transform has no logical form at all: translate(50%) pushes right in both directions, outward in English and inward over the icon in Arabic, so sign the offset as translate(calc(50% * var(--dir)), -50%) with --dir set to -1 under [dir="rtl"].

The plus sign in the cap

never mirrors

In a string like 99+ the plus sign carries bidi class ES, and rule W4 folds an ES into the number only when it falls between two European digits, which it does not here since nothing follows it. Left neutral, it takes the direction of the paragraph, so an Arabic page renders the string 99+ as +99. Isolate the digits and the sign together in one element carrying the dir="ltr" attribute: the attribute alone pulls unicode-bidi: isolate from the browser stylesheet, while CSS direction: ltr isolates nothing and has to be paired with unicode-bidi: isolate.

Which digits you ship

never mirrors

Pick one numeral system for the whole product, Arabic-Indic U+0660 to U+0669 or Latin U+0030 to U+0039, and set it in one place with new Intl.NumberFormat("ar", { numberingSystem: "latn" }) instead of per component. The two are not the same thing in bidi either: Latin digits are class EN and Arabic-Indic digits are class AN, and rule W4 binds a plus or a minus between two EN digits only, so the same sign stays neutral among Arabic-Indic ones. Watch the font as well, since plenty of UI fonts carry no Arabic-Indic figures and the browser falls back inside the badge alone, and font-variant-numeric: tabular-nums does not convert between the systems, it only picks among the figure sets the font already has.

The dot and its offset

mirrors

A dot is a symmetric circle, so nothing in its shape mirrors, and a blanket [dir="rtl"] .icon { transform: scaleX(-1) } must never reach it. Its offset is a different matter: top: -2px with right: -2px keeps the dot in the physical top right, which is the start corner in Arabic, so it lands opposite every sibling badge built from logical properties in the same bar. Write the offset as inset-block-start and inset-inline-end, leave the box-shadow: 0 0 0 2px var(--surface) ring alone because it is symmetric, and give any one-sided clip-path a mirrored version, since clip-path has no logical form.

A badge after a text label

mirrors

A badge that follows a tab label is an element in the line flow rather than an absolutely positioned one, and its side comes from the line: after the word means to the left of it in Arabic. Set the space with margin-inline-start on the badge or gap on the container, never margin-left, which after the flip glues the badge to the word on one side and doubles the gap on the other. Keep the badge after the label in DOM order in both languages and let direction do the visual swap, because flex-direction: row-reverse flips a second time and leaves reading order disagreeing with the tree order a screen reader follows.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

ARIArole="status"Carries an implicit aria-live="polite" and aria-atomic="true", so the whole region text is read instead of the one digit that changed.
HTMLdir="ltr"The attribute brings unicode-bidi: isolate along from the browser stylesheet, which keeps the plus sign attached to its digits.
Material UI<Badge badgeContent={4} max={99} showZero>max is the cap, showZero forces the zero case, and variant="dot" drops the number entirely.
Ant Design<Badge count={5} overflowCount={99} />Ant names the cap overflowCount and hides the badge at count={0} unless showZero is set.
Chakra UI<Badge variant="subtle">Chakra's Badge is a static text label in the flow, not a counter glued to a corner, and that is where the naming confusion starts.
SwiftUI.badge(3)Attaches to a List row or a tab, and hides itself when the value is zero.
CSSinset-inline-endThe logical property that puts the badge in the correct corner with no RTL-specific rule.

See also

Updated · 2026-08-19