> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/indicator.html
> Source: specs/components/indicator/indicator.md, specs/components/indicator/indicator-a11y.md, specs/components/indicator/capabilities.json

---

# Indicator · Oymyakon DS 3

> A small non-interactive marker that shows a status or a count on top of another element —
> an icon, tab, avatar, or menu item — without taking part in its layout.

**Version:** 3.1.2 · **Status:** Draft · **Figma:** `[Indicator] 3.1` (node `7552:6614`, page `7552:6577`)
**Platform:** iOS 3.1 (`DsSimpleIndicator` / `DsIndicator`) · Android 3.1 (`DsIndicator`) · Flutter —

---

## 1. Description

Indicator overlays a parent element and never affects its layout. Two types on two sizes:

| Type | What it is |
|---|---|
| **Simple** | A bare dot — presence is the message |
| **Number** | A count pill — a minimum square that grows with the digits, capped at `99+` |

The Figma line ships the `[Indicator] 3.1` wrapper with one `Size` instance-swap over two sets —
`↳M-Indicator` and `↳L-Indicator` — each carrying `Type` (Simple | Number) and `Border`
(No | Yes, default **No**) plus a `Number` text property (default `"1"`).

On the web the whole family is one root class: `.indicator` with `--m`/`--l` size and
`--simple`/`--number` type modifiers, and the three `--indicator-*` custom properties as the
colour contract.

---

## 2. Anatomy

```
Indicator (.indicator)            min square (Number) or fixed dot (Simple) · radius s32
├── label                           the count text — Number type only
└── Border (.indicator--border)     2dp OUTSIDE ring — off by default
```

| Part | Type | Default | Description |
|---|---|---|---|
| **label** | text content | Number type only | The count string, capped at `99+`. Caption (M) / CompactBody (L), centred. A composition that animates the count wraps it in the optional `.indicator__count` span — see § 5 |
| **Border** | outside ring | **Off** | A 2dp ring drawn OUTSIDE the fill (`.indicator--border`): it separates the marker from busy content underneath and never moves the layout or shrinks the fill |

Simple has no children — the component is the dot itself.

### RTL layout

| Element | RTL behaviour |
|---|---|
| Indicator box | Symmetric — nothing mirrors inside |
| Anchor position | Mirrors with the parent: a top-end anchor stays top-end (visually top-left). The anchoring is the parent's job — see § 8 |
| Count text | Numerals — unchanged |

---

## 3. Variants and sizes

| Axis | Values | Default | Web mechanism |
|---|---|---|---|
| **Size** | `m` · `l` | `m` | `.indicator--m` / `.indicator--l` |
| **Type** | `simple` · `number` | `simple` | `.indicator--simple` / `.indicator--number` |
| **Border** | off · on | **off** | `.indicator--border` |
| **Fill / Content / Border colour** | any DS semantic token | `--accent-red2` / `--text-and-icon-always-light` / `--background-primary` | The `--indicator-fill` / `--indicator-content` / `--indicator-border` custom properties, re-pointed on the instance |

There is no Custom component set in Figma — a non-red indicator is an instance-level fill
override there, and a custom-property re-point on the web.

---

## 4. States

Indicator is non-interactive — no press, hover, or focus. **Standard** is the only rest form.

| State | Description |
|---|---|
| **Standard** | Visible, with the dot or the count |
| **Hidden** | Count is 0 — the component is not rendered at all (`.indicator--hidden` or removed from the DOM) |
| **Entering / Exiting** | The two transit phases of the scaleS lifecycle — see § 5 |

---

## 5. Animation and behavior

Source: [`tokens/rules/motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). Indicator
declares no motion tokens of its own — it consumes the DS-wide `scaleS` pair from
`tokens/generated/motion.css`.

| Event | Token | Notes |
|---|---|---|
| Appear | `var(--component-scale-s-appear)` | scale 0 → 1 + opacity, 200 ms — the scaleS pattern (numerically identical to the pair iOS `ScaleShortAnimation` plays: `short3` + standard-ease-in-out) |
| Disappear | `var(--component-scale-s-hide)` | scale 1 → 0 + opacity, 200 ms |
| Count change | `var(--transforming-state-fast)` | The numericText roll — see below |

- A count change plays the **numericText roll** — the web mirror of iOS
  (`contentTransition(.numericText())` on iOS 16+, the X-axis flip on iOS 15): the outgoing value
  rolls up and fades on `var(--transforming-state-fast)`, the text swaps, the incoming value
  rolls in from below on the same token. The value sits in an optional `.indicator__count`
  wrapper when a composition animates it; a static count stays bare text. The root clips the
  travel.
- The width **glides** with the roll: the composition pins the measured from/to widths (FLIP)
  and tweens between them on the same `var(--transforming-state-fast)` via the
  `.indicator--count-resizing` class while the new value rolls in — the way SwiftUI animates the
  frame alongside numericText. This is a recorded, deliberate deviation from the
  compositor-only guidance: a badge-sized layout tween, the one place this component animates
  layout. Outside a driven count change the width is never animated.
- Reduced motion is handled at the token: `motion.css` zeroes every `--component-*` inside
  `prefers-reduced-motion: reduce`. The component carries no override of its own.

Web lifecycle classes: `.indicator--entering` (pre-appear), `.indicator--visible`,
`.indicator--exiting`, `.indicator--hidden`.

---

## 6. Color tokens

The three `--indicator-*` custom properties are the whole colour contract:

| Element | Token | Notes |
|---|---|---|
| Fill (`--indicator-fill`) | `var(--accent-red2)` | **Both sizes.** |
| Content (`--indicator-content`) | `var(--text-and-icon-always-light)` | The count text |
| Border ring (`--indicator-border`) | `var(--background-primary)` | Consumed only with `.indicator--border`; matches the page ground so the ring reads as a cut-out |

Any DS semantic token may replace any of the three on an instance.

---

## 7. Typography

| Size | Style | Tokens |
|---|---|---|
| M | Caption/Caption | `var(--text-caption-caption-*)` — family + size + weight + line-height set together |
| L | Body/Compact Body | `var(--text-body-compact-body-*)` — set together |

The count colour is `currentColor` from `--indicator-content` — never declared on the text.

---

## 8. Spacing

| Property | Token | Value @100% | Notes |
|---|---|---|---|
| Simple dot M | `var(--sp-s8)` | 8 × 8 | |
| Simple dot L | `var(--sp-s12)` | 12 × 12 | |
| Number min box M | `var(--sp-s16)` | 16 × 16 | Min square; width grows with the count |
| Number min box L | `var(--sp-s24)` | 24 × 24 | Min square; width grows with the count |
| Number padding-h M | `var(--sp-s4)` | 4 | |
| Number padding-h L | `var(--sp-s6)` | 6 | |
| Border radius | `var(--sp-s32)` | 32 | Always ≥ half the height — the box stays a full pill |
| Border ring | `var(--sp-s2)` | 2dp | Outside the fill; no layout shift |
| Margin | `var(--sp-s0)` | 0 | The parent owns the offsets |

Sizing scales with the SP mode **capped at ×130%** (the Figma bindings sit on the 130%-ratio
collection).

### Positioning

Indicator is always positioned by its **parent** — it ships no margins or anchors of its own.
The DS way to seat one on a component is the
[DecorateContainer](https://super-dollop-pzmo65r.pages.github.io/decorate-container.md) primitive: its slots carry the 3×3
anchoring, the offsets, and the RTL mirroring. Free-form compositions use
`position: absolute` inside a `position: relative` wrapper.

---

## 9. Usage context

### When to use Indicator

- Unread or cart counts on a tab, icon, or avatar.
- A status dot (online, attention) next to a user element.
- The decoration slot of a DecorateContainer composition.

### When not to use Indicator

- When the label is text, not a number — that is a Tag.
- As a standalone element with no parent to mark — the meaning lives in the pairing.
- Counts above two digits are capped: `100+` renders as `99+`, never the full number.

### Count overflow

| Count | Rendered |
|---|---|
| 0 | Nothing — the indicator is not rendered |
| 1–99 | The exact number |
| 100+ | `99+` |

### Related components

| Component | Relation |
|---|---|
| Tag | Inline text label; not overlaid |
| DecorateContainer | Hosts Indicator as a predefined decoration with 3×3 anchoring |
| Squircle / IconContainer | Common parents an Indicator marks |

---

## 10. Accessibility

Brief summary. Full spec: [`indicator-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/indicator.md).

- Always decorative to assistive technology: `aria-hidden="true"` on the indicator itself.
- The **parent** carries the meaning in its accessible name — `aria-label="Messages, 3 unread"`.
- Dynamic count changes announce through a visually-hidden `aria-live="polite"` region, never
  through the indicator.
- Count 0: removed from the DOM — gone from the accessibility tree with it.
- Never focusable; no touch target requirement (non-interactive).

---

## 11. Analytics and coverage contract

| Field | Value |
|---|---|
| `data-ds-component` | `indicator` |
| Coverage unit | Yes. One Indicator root = one DS component instance |
| Tap target | None — non-interactive; taps belong to the parent element |
| Root actions | none |

Required root attributes in prototypes:

```html
<span
  class="indicator indicator--number indicator--m"
  data-ds-component="indicator"
  data-ds-component-id="chat.tab.unread"
  data-ds-variant="number"
  aria-hidden="true">3</span>
```

A custom-coloured instance re-points the custom properties and keeps the same attributes:

```html
<span
  class="indicator indicator--simple indicator--m"
  data-ds-component="indicator"
  data-ds-variant="simple"
  style="--indicator-fill: var(--accent-green2);"
  aria-hidden="true"></span>
```

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.1.2 | 2026-09-17 | Count-change animation aligned to iOS: the numericText roll (out up + fade, swap, in from below) on `Transforming/State/Fast`, via the optional `.indicator__count` wrapper; the root clips the travel. The width glides with the roll — a FLIP tween on the same token (`.indicator--count-resizing`), a recorded deviation from the compositor-only guidance mirroring SwiftUI's frame animation. Appear/hide note added: web `scaleS` is numerically the pair iOS `ScaleShortAnimation` plays. |
| 3.1.1 | 2026-09-17 | Aligned to the live Figma `[Tag]`-era conventions of `[Indicator] 3.1`: **L fill corrected to `--accent-red2`** (the spec's `--accent-red1` never matched the Figma set); **Border off by default** — `.indicator--border` opt-in 2dp OUTSIDE ring (`--background-primary`), replacing the always-on inside border; `.indicator--default` retired — the base `.indicator` carries the red trio via the new `--indicator-fill/content/border` custom properties; radius `999px` → `var(--sp-s32)` (the Figma value); the claimed semibold-600 label override removed (Figma and the build both use the token weight); the count-change width animation dropped (width is not a compositor property — the pill re-hugs instantly); scaling documented as cap ×130%; motion aliases removed — the build consumes `--component-scale-s-appear/hide` directly; positioning section re-pointed at DecorateContainer; Analytics and coverage contract section added. |
| 3.1.0 | 2026-05-19 | Initial spec: two types (Simple/Number), two sizes (M/L), layout tokens, color tokens, typography tokens, positioning anchors, animation patterns, RTL, a11y summary. |

---

# Indicator — Accessibility

Non-interactive marker: the core accessibility fact is that the Indicator itself is **always
decorative** — the meaning (a count, a status) belongs to the parent element's accessible name.
The indicator is hidden from assistive technology on every platform; nothing about it is
focusable, and no touch target applies.

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Indicator (root) | — | — | Not important for accessibility (`importantForAccessibility="no"`) | — |
| Count text | — | — | Read only through the parent's `contentDescription` | — |
| Parent element (icon, tab, avatar) | Includes the meaning: "Messages, 3 unread" | — | The parent's own role | — |

- The count lives in the **parent's** `contentDescription` — the indicator never speaks for
  itself.
- A count change re-announces through the parent's live region (`accessibilityLiveRegion` =
  polite) or an equivalent polite announcement — never by focusing the indicator.

### Edge states

- **Disabled / skeleton:** not applicable — the component has no disabled, error, or loading
  state; Standard is the only rest form.
- **Count = 0:** the indicator is not rendered — it leaves the accessibility tree with the DOM.
  The parent's description drops the count phrase ("Messages", not "Messages, 0 unread").
- **Simple dot:** presence is the message — the parent's description carries it in words
  ("Online", "Attention required"), the dot stays silent.
- **Border on/off:** purely visual — no announcement change.
- **RTL:** anchoring mirrors with the parent's layout; announcements are unchanged.
- **Reduced motion:** the scaleS appear/hide zeroes at the token; announcements are unchanged.

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Indicator (root) | — | — | Excluded (`isAccessibilityElement = false`) | — |
| Count text | — | — | Read only through the parent's `accessibilityLabel` | — |
| Parent element | Includes the meaning: "Messages, 3 unread" | — | The parent's own trait | — |

- The parent composes its `accessibilityLabel` from its own name plus the count; the indicator
  contributes nothing of its own.
- A count change posts a polite `UIAccessibility` announcement from the parent when the change
  matters mid-screen; silent otherwise.

### Edge states

- **Dimmed / disabled:** not applicable — the component has no disabled, error, or loading
  state; Standard is the only rest form.
- **Count = 0:** the indicator is removed; the parent's label drops the count phrase.
- **Simple dot:** the parent's label carries the status in words; the dot is excluded.
- **Border on/off:** purely visual — no announcement change.
- **RTL / Reduced motion:** layout and motion only; announcements are unchanged.

---

## Web preview (reference)

The preview page and prototypes mark the indicator up so the same contract holds:

- `aria-hidden="true"` on **every** indicator, both types — the digit inside a Number pill is
  never read directly:

  ```html
  <button aria-label="Messages, 3 unread">
    <svg aria-hidden="true"><!-- icon --></svg>
    <span class="indicator indicator--number indicator--m"
          data-ds-component="indicator" data-ds-variant="number"
          aria-hidden="true">3</span>
  </button>
  ```

- **A non-interactive parent** (a static icon with no native role) takes `role="img"` +
  `aria-label` instead of the button pattern — never as a substitute for a tab's or button's
  own role.
- **Dynamic counts** announce through a visually-hidden polite live region — a sibling, never
  the indicator itself:

  ```html
  <span class="sr-only" aria-live="polite" aria-atomic="true">3 unread messages</span>
  ```

- **Count = 0:** remove the element from the DOM (preferred) or apply `.indicator--hidden`
  (`display: none`) — both take it out of the accessibility tree. Never leave an empty pill.
- **Never focusable:** no `tabindex`, no role, no interactive attributes — the parent is the
  control.
- **Reduced motion:** handled at the token — `motion.css` zeroes
  `--component-scale-s-appear/hide` inside `prefers-reduced-motion: reduce`; the component ships
  no override of its own. The animation carries no meaning: the count is always available as
  text through the parent regardless.

### Colour and contrast

| Pairing | Tokens | Status |
|---|---|---|
| Count on the fill (both sizes) | `--text-and-icon-always-light` on `--accent-red2` | **Known shortfall** — see below |
| Simple dot on the page | — | None required — decorative, no text |
| Border ring | `--background-primary` | None required — decorative separation |

**Contrast — known shortfall:** white text on `Accent/Red 2` measures **3.98:1 Light /
3.47:1 Dark** (measured 2026-09-03 on the identical token pair in
[`notification-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/notification.md)) — below the 4.5:1 body-text
target, and the Indicator count is smaller still (12/14 px Book). The pairing is the palette's
own, shared with Notification / HeaderAlert / Snackbar; resolution is a token-level call for the
colour owner. Mitigation is built into the contract regardless: the count is always duplicated
in the parent's accessible name, so no information lives only in the pill.

A custom fill (`--indicator-fill` re-pointed) carries the same 4.5:1 duty for the count against
the new fill; the pairing check is on whoever re-points it. Never rely on the dot's colour alone
to convey a status — the parent's words carry it.

---

## Machine contract — `specs/components/indicator/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "indicator",
  "name": "Indicator",
  "version": "3.1.2",
  "description": "A small non-interactive marker showing a status (Simple dot) or a count (Number pill, capped at 99+) on top of another element. Always decorative to assistive technology — the parent carries the meaning.",
  "files": {
    "spec": "specs/components/indicator/indicator.md",
    "a11y": "specs/components/indicator/indicator-a11y.md",
    "preview": "src/indicator.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.30.1 (components)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "[Indicator] 3.1": {
        "key": "8fc1d663bb2d86ff52c29a6c2f27aa9f560bf4bc",
        "note": "The wrapper component (node 7552:6614): one INSTANCE_SWAP prop `Size` choosing the M or L set."
      },
      "↳M-Indicator": {
        "key": "3a45d76a96bb6fcc25a5f6a9a70fc21607a43798",
        "note": "Type (Simple | Number) × Border (No | Yes) + a `Number` text property."
      },
      "↳L-Indicator": {
        "key": "2f1270e80195341b8f1639a2f23c0d91ca819007",
        "note": "Same axes as M at the L geometry."
      }
    },
    "capturedAt": "2026-09-17"
  },
  "root": {
    "class": "indicator",
    "dataDsComponent": "indicator"
  },
  "anatomy": {
    "label": {
      "class": "indicator",
      "optional": true,
      "onlyWhen": {
        "type": [
          "number"
        ]
      },
      "notes": "The count is the root's own text content — no child element. Caption (M) / CompactBody (L), capped at 99+, colour via currentColor."
    },
    "count": {
      "class": "indicator__count",
      "optional": true,
      "onlyWhen": {
        "type": [
          "number"
        ]
      },
      "notes": "Optional wrapper a composition uses to animate the count (the numericText roll); a static count stays the root's bare text."
    }
  },
  "axes": {
    "size": {
      "title": "Size",
      "type": "enum",
      "values": [
        "m",
        "l"
      ],
      "default": "m",
      "css": {
        "modifierTemplate": ".indicator--{value}"
      },
      "figma": {
        "kind": "component-set",
        "values": {
          "m": "↳M-Indicator",
          "l": "↳L-Indicator"
        },
        "notes": "The wrapper's `Size` INSTANCE_SWAP picks the set."
      }
    },
    "type": {
      "title": "Type",
      "type": "enum",
      "values": [
        "simple",
        "number"
      ],
      "default": "simple",
      "css": {
        "modifierTemplate": ".indicator--{value}"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Type"
      }
    },
    "border": {
      "title": "Border",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".indicator--border"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Border",
        "notes": "No | Yes, default No. A 2dp OUTSIDE ring — on the web a spread shadow, so it never moves the layout."
      }
    },
    "count": {
      "title": "Count",
      "type": "number",
      "min": 0,
      "max": 99,
      "step": 1,
      "default": 1,
      "constraints": [
        "0 renders nothing at all.",
        "Anything above 99 renders as the string 99+."
      ],
      "css": {
        "mechanism": "the root's text content (bare, or in .indicator__count when animated); the count-change roll = indicator--count-out / indicator--count-in phases + the width FLIP via indicator--count-resizing, all on var(--transforming-state-fast)"
      },
      "figma": {
        "kind": "none",
        "notes": "In Figma this is the `Number` TEXT component property on both sets (default \"1\") — not a variant, and the schema has no text-property kind yet (owner flag): `none` + this note is the closest honest encoding."
      }
    },
    "fill": {
      "title": "Fill",
      "type": "token",
      "customizable": "any DS semantic colour token",
      "default": "--accent-red2",
      "tokens": [
        "--accent-red2",
        "--accent-green2",
        "--accent-blue2",
        "--accent-orange2"
      ],
      "css": {
        "customProperty": "--indicator-fill"
      },
      "figma": {
        "kind": "none",
        "notes": "No Custom set — a non-red indicator is an instance-level fill override in Figma. Red 2 is the default on BOTH sizes."
      }
    },
    "content": {
      "title": "Content",
      "type": "token",
      "customizable": "any DS semantic colour token",
      "default": "--text-and-icon-always-light",
      "tokens": [
        "--text-and-icon-always-light"
      ],
      "css": {
        "customProperty": "--indicator-content"
      },
      "figma": {
        "kind": "none",
        "notes": "The count colour, via currentColor. The 4.5:1 duty against the fill moves to whoever re-points either token."
      }
    },
    "borderColor": {
      "title": "Border colour",
      "type": "token",
      "customizable": "any DS semantic colour token",
      "default": "--background-primary",
      "tokens": [
        "--background-primary"
      ],
      "css": {
        "customProperty": "--indicator-border"
      },
      "figma": {
        "kind": "none",
        "notes": "Consumed only while border=true; matches the page ground so the ring reads as a cut-out."
      }
    }
  },
  "states": {
    "static": [
      "standard",
      "hidden",
      "entering",
      "exiting"
    ]
  },
  "constraints": [
    "Non-interactive: no press, hover, focus, or touch target — the parent element is the control and owns the tap.",
    "Always decorative to assistive technology (aria-hidden=\"true\" / importantForAccessibility=\"no\" / isAccessibilityElement=false); the parent's accessible name carries the count or status.",
    "Count 0 renders nothing; counts above 99 render as 99+ — never the raw number.",
    "The indicator ships no margins or anchors: the parent owns the seat (the DS seat is the DecorateContainer primitive).",
    "The border ring never moves the layout: 2dp OUTSIDE the fill, spread shadow on the web.",
    "Sizing scales with the SP mode capped at ×130% (the Figma bindings sit on the 130%-ratio collection).",
    "A count change plays the numericText roll (Transforming/State/Fast, transform+opacity via .indicator__count) and the width glides with it — a FLIP tween on the same token via .indicator--count-resizing, the component's one recorded layout-animation deviation. Outside a driven count change the width never animates.",
    "No motion tokens are declared on the component; appear/hide consume the DS-wide scaleS pair (--component-scale-s-appear/hide)."
  ],
  "analytics": {
    "dataDsComponent": "indicator",
    "targets": [
      "none — non-interactive; taps belong to the parent element"
    ],
    "notes": "data-ds-variant carries the type (simple | number); the size lives in the class only."
  },
  "rtl": {
    "supported": true,
    "notes": "The box is symmetric — nothing mirrors inside. The anchor position mirrors with the parent's layout (logical insets on the web; DecorateContainer handles it in compositions)."
  }
}
```
