> ## 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/progress-button.html
> Source: specs/components/progress-button/progress-button.md, specs/components/progress-button/progress-button-a11y.md, specs/components/progress-button/capabilities.json

---

# ProgressButton · Oymyakon DS 3

> A timer button: a Button preset with a progress line laid over its fill, which runs for a set time
> and moves the person to the next step when it ends.

**Version:** 3.1.3 · **Status:** Ready · **Figma:** `[ProgressButton] 3.0` — page `24554:4657`,
wrapper `[ProgressButton] 3.0` (node `25035:3315`, a `Size` swap over the four sets),
`MainCTA-ProgressButton` (node `25017:3391`), `L-ProgressButton` (node `25013:3201`),
`M-ProgressButton` (node `25017:4517`), `S-ProgressButton` (node `25024:2754`), base
`customProgressButton` (node `24554:7677`), `🟡ProgressLine` (frame `24555:7879`). Specification
frame `24583:16828`

---

## 1. Description

ProgressButton is an action with a deadline. It offers a choice that expires — accept an offer,
confirm a driver, keep a search running — and shows the time left as a line that moves across the
button. When the time ends, the screen decides what happens: the request is declined, the screen
closes, or the next step opens. The button itself only reports that the time is up.

It is built on [Button](https://super-dollop-pzmo65r.pages.github.io/button.md): the same four configurations (`Main CTA`, `L`, `M`,
`S`), the same slots, rows and text styles, and the same 3.4 scaling. The progress line is the one
part Button does not have. The base is **customProgressButton** (node `24554:7677`), the
customButton of this family: its slots stay open and its fill can be recoloured.

The default time is 15 seconds. A ProgressButton stays on screen for at least 15–20 seconds, so a
person has time to read what they are accepting and decide.

---

## 2. Anatomy

```
ProgressButton           .button .button--{size} .progress-button     hug x minimum height per size
├── ProgressLine         .progress-button__line                       absolute, behind the content — required
│   └── Track            .progress-button__track                      the line strip with its slanted edge
├── StartSlot            .button__start-slot                          optional, off by default (not on Main CTA)
├── StartText            .button__start-text                          required
│   ├── Title row        .button__title-row                           required
│   └── Subtitle row     .button__subtitle-row                        optional, off by default (not on Main CTA)
└── EndSlot              .button__end-slot                            optional, off by default (not on Main CTA)
```

| Element | Class | Required | Notes |
|---|---|---|---|
| Root | `.button` + size class + `.progress-button` | required | The focusable and clickable element. Clips the line to its radius |
| ProgressLine | `.progress-button__line` | required | Figma `🟡ProgressLine` — an absolute layer filling the root, behind every slot. Decorative to assistive technology; the time is announced as a value (§10) |
| Track | `.progress-button__track` | required | The strip that covers the part of the fill the time has used: `Surface/DarkOverlay`, with a `s2` `Surface/WhiteOverlay` band along its slanted leading edge |
| StartSlot, StartText, EndSlot | Button classes | per Button | The slot contract, the rows and the optical-balance paddings are Button's — [`button.md`](https://super-dollop-pzmo65r.pages.github.io/button.md) §2 and §8 |

The line sits under the content, so the label, the icons and the Loader stay on top of it at
every point of the run. The track covers the brand fill from the end side of the button; the
uncovered part, from the start side, is the time that is left.

The slanted edge is 64° from the horizontal at every size and every height. A taller button gets a
longer edge, not a steeper one (Figma Layout note: "the line angle should increase proportionally,
not just in height"). The edge and its `s2` band are not customisable.

---

## 3. Variants and sizes

| Parameter | Values |
|---|---|
| Size | `Main CTA` · `L` · `M` · `S` (separate component sets) |
| State | `Standard` · `Disabled` · `Skeleton` (Figma: the `Disabled` and `Skeleton` booleans) |
| Loading | `Off` · `On` (default `Off`) |
| RTL | `Off` · `On` (default `Off`) — every size, `Main CTA` included |
| Direction | `Countdown` (default) · `Countup`. Web: `.progress-button--countup` |
| Time | a whole number of seconds, default 15. Web: `--progress-button-time` (unitless). Whole seconds because reduced motion steps the line once per second (§5) |
| StartSlot / EndSlot | booleans `Slot#1` / `Slot#2`, off by default; absent on `Main CTA` |
| Subtitle row | per Button — `L` / `M` / `S` and the base; absent on `Main CTA` |
| Title multiline / Subtitle multiline | per Button 3.4 — `.button__title-row--multiline`, `.button__subtitle-row--multiline` |
| Config | `customProgressButton` — the base, with `Slot#1`, `Slot#2` and `Progress Line` booleans. Web: `.button .progress-button .progress-button--custom` |
| Elapsed | seconds already run, default 0 — to resume a run after a re-render, or to show one frame of it. Web: `--progress-button-elapsed` |

There is no `Style` axis. The fill is `Background/Brand`; a button in another colour is a
customProgressButton with that fill set (Figma Layout, "color customization").

**Direction.** `Countdown` starts with the fill uncovered and the track grows from the end side
until it covers the whole button ("progress decreases"). `Countup` starts covered and the track
withdraws until the fill is whole ("progress fills up"). In both, the line runs linearly over the
whole time. When the time ends the button stays as it is — covered after a countdown, uncovered
after a countup — and the screen applies whatever comes next.

Figma draws the run as five line states: `empty` (fully covered), `start`, `middle`, `end` and
`full` (no track). They are frames of one continuous movement, not states a product team picks;
the web build draws every point between them.

Geometry — width hugs the content, height has a minimum per size and grows only with wrapped text:

| Configuration | Height | Horizontal padding | Vertical padding | Gap | Radius | Class |
|---|---|---|---|---|---|---|
| customProgressButton — base | min `s56` | `s12` | `s8` | `s4` | `s20` | `.progress-button--custom` |
| `Main CTA` | `s64` | `s16` | `s8` | `s12` | `s20` | `.button--cta` |
| `L` | min `s56` | `s12` | `s8` | `s4` | `s20` | `.button--l` |
| `M` | min `s48` | `s12` | `s8` | `s4` | `s20` | `.button--m` |
| `S` | min `s40` | `s12` | `s8` | `s4` | `s20` | `.button--s` |

**Scaling (3.1).** Height, radius and vertical padding stop at 130% on every size (`cap130`). The
horizontal padding and the gap stay at 100% — Figma binds them to `SP/100% ratio`.

| Configuration | Height — 100 / 130 / 150% | Radius — 100 / 130 / 150% |
|---|---|---|
| `Main CTA` | 64 / 83.2 / 83.2 — `cap130` | 20 / 26 / 26 — `cap130` |
| `L` | 56 / 72.8 / 72.8 — `cap130`, a minimum | 20 / 26 / 26 — `cap130` |
| `M` | 48 / 62.4 / 62.4 — `cap130`, a minimum | 20 / 26 / 26 — `cap130` |
| `S` | 40 / 52 / 52 — `cap130`, a minimum | 20 / 26 / 26 — `cap130` |

Each value takes the SP family Figma binds: `SP/130% ratio` → `var(--sp-130-sN)` (`cap130`),
`SP/100% ratio` → `var(--sp-100-sN)` (fixed), `SP/150% ratio` → `var(--sp-sN)` (full). The height
is a minimum on `L` / `M` / `S` and fixed on `Main CTA`, as in Button 3.4. The radius differs from
Button: Button holds `M` and `S` at a fixed 20, ProgressButton caps them at 26 like `L` and
`Main CTA` — the master binding, confirmed by the owner (2026-11-04).

The slot icons and the Loader follow the masters:

| Element | `Main CTA` | `L` | `M` | `S` |
|---|---|---|---|---|
| Slot IconContainer | — | `s24`, `full` | `s20`, `cap130` | `s16`, `full` |
| Loader | `s32`, `fixed` | `s24`, `cap130` | `s16`, `cap130` | `s16`, `cap130` |

The slot icons are Button's (§3 of its spec). The Loader differs: Button scales it `full`, while the
ProgressButton masters bind it to `SP/130%` on `L` / `M` / `S` and to `SP/100%` on `Main CTA`.

> **Figma flags (3.1).** (1) The masters' root is `FIXED` × `FIXED` and no master shows a wrapped
> label; the web takes Button 3.4's minimum height and multiline on the owner's call (2026-11-04).
> (2) The Specification frame names `Background/Disabled`, `TextAndIcon/Secondary` and
> `Border/Active` for the fill and the rows; the masters bind `Surface/OnWhite` and
> `TextAndIcon/AlwaysDark` / `TextAndIcon/Disabled`, and `Background/Disabled` does not exist in the
> DS. The masters are followed. (3) The frame draws Pressed as `Surface/PressedOverlay` at 50%
> opacity; the web keeps Button's scale-only press (owner, 2026-11-04 — opacity over a colour token
> is outside the colour rules). (4) The `M` and `S` Loading masters use Loader `XS (16)`
> `Style=OnBrand`; the web draws the Loader in the label colour, like Button. (5) Loading and
> Skeleton masters keep the Standard width; the web takes Button's fixed widths (§4). (6) The page
> is still versioned 3.0 while its bindings carry the 3.4 scaling; the web build is 3.1.

---

## 4. States

| State | Description |
|---|---|
| Standard | Rest. The line runs over the brand fill; label and icons in `TextAndIcon/AlwaysDark` |
| Pressed | Held. Scales to 95% via `pushButton`, as Button. A motion step, not a variant — no overlay |
| Loading | StartText and both slots give way to a Loader. The line stays and stops where it was. Width as Button's Loading: `s72` on `L` and `M`, `s64` on `S`; `Main CTA` keeps its full width |
| Disabled | Non-interactive. Fill `Surface/OnWhite`, label and icons `TextAndIcon/Disabled`, no line. The usual state once the time is up |
| Skeleton | Placeholder: the shape alone, filled by [Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md), no line. Width as Button's Skeleton |

The base, customProgressButton, is L-sized: in `Loading` it takes L's Loader (`cap130`) and in
`Loading` and `Skeleton` L's fixed `s72` width.

`Disabled` and `Skeleton` are the same on every size (Figma States note). `Loading` combines with
`Standard`; `Skeleton` pairs only with `Loading=Off`.

Time running out is not a state of its own. The button reports the end (§5) and the screen moves
it on — usually to `Disabled`, or off the screen altogether.

---

## 5. Animation and behavior

Motion follows [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). The press and the state
changes are Button's; the line is ProgressButton's own.

| Event | Pattern | Token | CSS |
|---|---|---|---|
| Press | `pushButton` | `--component-push-button-press` | `transform` (100% → 95%), `background-color` |
| Release | `pushButton` | `--component-push-button-release` | `transform` (95% → 100%), `background-color` |
| Standard ↔ Disabled | `Transforming/State/Default` | `--transforming-state-default` | `color` |
| Loader, while loading | `Patterns/Spin` | `--pattern-spin` | `animation` — 800ms, linear, infinite |
| Line, while running | — (a timer, see below) | `--curve-linear` · `--progress-button-time` | `animation` on `transform` of the track |

**The line is a timer, not a transition.** Its duration is the time the screen sets (15 s by
default), well past the 600 ms ceiling of the motion budget, and no `Transforming` or `Patterns`
preset covers it. The curve is Figma's `linear` (0.25, 0.25, 0.75, 0.75), which is the primitive
`--curve-linear` in `tokens/generated/motion.css`. Applying it directly is a recorded exception to
the primitives rule (owner, 2026-11-04): the line consumes `var(--curve-linear)` and no DS preset is
declared for it.

```css
.progress-button__track {
  animation: progress-button-run calc(var(--progress-button-time) * 1s) var(--curve-linear) both;
  animation-delay: calc(var(--progress-button-elapsed) * -1s);
}
.progress-button--countup .progress-button__track { animation-direction: reverse; }
.progress-button--paused .progress-button__track,
.button--loading.progress-button .progress-button__track { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .progress-button__track { animation-timing-function: steps(var(--progress-button-time), end); }
}
```

Only `transform` moves, so the run stays on the compositor. The track is skewed to draw its edge
and translated along the button to move it; the run never resizes a box. The line is a size
container, so the track measures the button in `cqw` / `cqh` and keeps the 64° edge at any height.

**Reduced motion.** The line is information — the time left — so it is kept rather than removed. It
moves in one step per second instead of gliding: `--curve-linear` is not one of the tokens
`motion.css` zeroes, and the step timing is ProgressButton's own override.

**The end of the run.** The track fires `animationend` when the time is up. The screen listens for
it and applies the next step; the button changes nothing on its own. Restarting the run is a host
action — the class that holds the animation is removed and set again.

**RTL.** The line mirrors: the track grows from the left, and its edge leans the other way (Figma
RTL: "Progress is mirrored"). Set `dir="rtl"` on the root or on an ancestor.

---

## 6. Color tokens

| Element | Token — Standard | Token — Disabled |
|---|---|---|
| Fill | `var(--background-brand)` | `var(--surface-on-white)` |
| Track | `var(--surface-dark-overlay)` | — no line |
| Track edge band | `var(--surface-white-overlay)` | — no line |
| Label, subtitle, icons, Loader | `var(--text-and-icon-always-dark)` | `var(--text-and-icon-disabled)` |
| Skeleton fill | `var(--skeleton-on-white)` (the Skeleton component) | — |

The track reads 1.58:1 against the uncovered fill — below WCAG 1.4.11's 3:1 for a graphic; see §10.

The track's two overlay tokens are alpha tokens, so they darken and lighten whatever fill sits
underneath — the brand fill by default, the custom fill on customProgressButton — without a second
pair of tokens. Their opacity is in the token; nothing multiplies it.

`AlwaysDark` on the brand fill is Button `Primary`'s pairing. Under the track the label reads on the
brand colour darkened by `Surface/DarkOverlay`; the contrast check is recorded in
[`progress-button-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/progress-button.md).

A customProgressButton fill is set with `--progress-button-fill`; its label colour is set together
with it, through the root's `color`.

---

## 7. Typography

The rows are Button's, with Button's styles and lines:

| Configuration | Title row | Subtitle row |
|---|---|---|
| customProgressButton | Heading 4 | Compact Body |
| `Main CTA` | Heading 2 — PP Agrandir | — no subtitle |
| `L` | Heading 4 | Compact Body |
| `M` | Main Body | Compact Body |
| `S` | Main Body | Caption |

Scaling follows the style: `cap130` on `Main CTA` (Heading 2), `full` everywhere else. The lines
follow Button 3.4 §7: one line with an end ellipsis by default, multiline per row, `Main CTA` two
lines at most.

The countdown itself is not printed on the button. The time left is shown by the line and announced
to assistive technology (§10); a screen that also prints the seconds puts them in the subtitle row.

---

## 8. Spacing

| Property | Token | Value — 100 / 130 / 150% |
|---|---|---|
| Minimum height — `L` / `M` / `S` | `var(--sp-130-s56)` / `var(--sp-130-s48)` / `var(--sp-130-s40)` | 56 / 72.8 / 72.8 · 48 / 62.4 / 62.4 · 40 / 52 / 52 |
| Height — `Main CTA` | `var(--sp-130-s64)` | 64 / 83.2 / 83.2 |
| Minimum height — customProgressButton | `var(--sp-130-s56)` | 56 / 72.8 / 72.8 |
| Radius — every size | `var(--sp-130-s20)` | 20 / 26 / 26 |
| Vertical padding — every size | `var(--sp-130-s8)` | 8 / 10.4 / 10.4 |
| Horizontal padding — `L` / `M` / `S` / base | `var(--sp-100-s12)` | 12, fixed |
| Horizontal padding — `Main CTA` | `var(--sp-100-s16)` | 16, fixed |
| Gap — `L` / `M` / `S` / base | `var(--sp-100-s4)` | 4, fixed |
| Gap — `Main CTA` | `var(--sp-100-s12)` | 12, fixed — one row, nothing to separate |
| StartText horizontal padding | `var(--sp-100-s8)` | 8, fixed; 0 on `Main CTA` |
| StartText vertical padding | `var(--sp-s0)` | 0 — Button's fixed 4 on `M` is not in the ProgressButton masters |
| Title / Subtitle gap | `var(--sp-100-s2)` | 2, fixed |
| Slot outer padding, toward the edge | `var(--sp-100-s4)` | 4, fixed |
| Slot IconContainer — `L` / base | `var(--sp-s24)` | 24 / 31.2 / 36 |
| Slot IconContainer — `M` | `var(--sp-130-s20)` | 20 / 26 / 26 |
| Slot IconContainer — `S` | `var(--sp-s16)` | 16 / 20.8 / 24 |
| Loader — `L` | `var(--sp-130-s24)` | 24 / 31.2 / 31.2 |
| Loader — `M` / `S` | `var(--sp-130-s16)` | 16 / 20.8 / 20.8 |
| Loader — `Main CTA` | `var(--sp-100-s32)` | 32, fixed |
| Track edge band | `var(--sp-s2)` | 2, not customisable |
| Track edge angle | — | 64°, not customisable |

The fixed spacing keeps the optical balance at 16 to an icon and 20 to a label in every mode, while
the height and the radius grow to 130%.

**Per size, at 100%.** Every number is the token's 100% value; the scaling column says how it moves
in the 130% and 150% modes.

| Property | customProgressButton | `Main CTA` | `L` | `M` | `S` | Scaling |
|---|---|---|---|---|---|---|
| Height | min `s56` | `s64`, fixed | min `s56` | min `s48` | min `s40` | `cap130` |
| Vertical padding (root) | `s8` | `s8` | `s8` | `s8` | `s8` | `cap130` |
| Horizontal padding (root) | `s12` | `s16` | `s12` | `s12` | `s12` | 100% |
| Gap — StartSlot / StartText / EndSlot | `s4` | `s12` (one row, nothing to separate) | `s4` | `s4` | `s4` | 100% |
| Slot outer padding, toward the edge | `s4` | — | `s4` | `s4` | `s4` | 100% |
| Slot IconContainer | `s24` | — | `s24` | `s20` | `s16` | `full` / `cap130` on `M` |
| StartText horizontal padding | `s8` | 0 | `s8` | `s8` | `s8` | 100% |
| StartText vertical padding | 0 | 0 | 0 | 0 | 0 | — |
| Title / Subtitle gap | `s2` | — | `s2` | `s2` | `s2` | 100% |
| Radius | `s20` | `s20` | `s20` | `s20` | `s20` | `cap130` |
| Side to an icon / to a label | 16 / 20 | 16 / 16 | 16 / 20 | 16 / 20 | 16 / 20 | — |

The vertical padding sits on the root of every size, `M` and `S` included (Button's `M` and `S` have
none, and `M` puts a fixed 4 on StartText instead). With the subtitle row on, the content plus that
padding passes the minimum: `M` and `S` both read 54 at 100% (20 + 2 + 16 + 2 × 8), where Button's
`M` stays at 48. Since Typography 3.6.0 the web text follows the SP modes, so the two rows grow with
the scope: `M` and `S` read 54 / 69.6 / 76.8 and `L` 56 / 72.8 / 76.8 at 100 / 130 / 150%. Above
100% the text rows, not the minimum, set the height (the vertical padding stays at 10.4). On one row
every size sits at its minimum.

| Component property | Value | Role |
|---|---|---|
| `--progress-button-time` | `15` | the run, in whole seconds — set per instance |
| `--progress-button-elapsed` | `0` | seconds already run — a negative `animation-delay` |
| `--progress-button-edge-skew` | `26deg` | the track's skew — 90° − the 64° edge. Declared on the track |
| `--progress-button-edge-run` | `48.77cqh` | how far the edge leans: tan(26°) of the height, in `cqh` of the line. Declared on the track, where `cqh` resolves against the line |
| `--progress-button-fill` | `var(--background-brand)` | the fill; re-pointed on customProgressButton |

The horizontal padding, the gap and the slot and text paddings resolve to Button's optical balance:
16 to an icon, 20 to a label. `Main CTA`'s `s12` gap has nothing to separate, since it holds a single
row.

---

## 9. Usage context

**When to use**

- An offer or a choice that expires on its own — accept a ride request, confirm a driver, keep
  waiting for a better fare.
- A step that continues by itself unless the person acts — "Next in 15 seconds".

**When not to use**

- An action with no deadline — that is [Button](https://super-dollop-pzmo65r.pages.github.io/button.md).
- Progress of a task the person is waiting for (an upload, a search) — the time is not theirs to
  use, so nothing is offered. That is a progress indicator, not a button.
- A deadline shorter than the time it takes to read the screen. Below 15 seconds the line pressures
  rather than informs.

A screen carries one running ProgressButton. Two timers side by side ask a person to track two
deadlines at once. Its alternative, such as "Decline", is a plain Button beside it.

**Related components** — [Button](https://super-dollop-pzmo65r.pages.github.io/button.md) (the base, the alternative action beside it),
[Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md), [IconContainer](https://super-dollop-pzmo65r.pages.github.io/icon-container.md) (the slots).

---

## 10. Accessibility

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

The root is a single button and the only focus stop. The line is hidden from assistive technology;
the time left is announced instead — as part of the label on focus ("15 seconds remaining"), and at
set points while the run goes on, more often in the final five seconds. When the time is up the
announcement says so together with what happened to the button.

A timer that ends an action is a WCAG 2.2.1 (Timing Adjustable) case. The 15-second default is a
usability minimum, not a 2.2.1 mechanism: the screen turns the limit off, adjusts or extends it, or
records the exception it relies on — an offer that is part of the activity (essential) or a live
event (real-time).

The line itself is 1.58:1 against the uncovered fill, below the 3:1 of WCAG 1.4.11. It passes only as
a supplementary picture of a time also shown as text — the seconds in the subtitle row. Owner ruling
2026-11-12: the gap is accepted, and a screen where the deadline matters prints the seconds; the subtitle
row stays off by default and the track keeps its token.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `progress-button` |
| `data-ds-preset` | `custom-progress-button` (base) · `main-cta` · `l` · `m` · `s` |
| `data-ds-variant` | `countdown` · `countup` |
| Coverage unit | yes |
| Tap target model | root — the whole button is the only target |
| Actions | `tap` |
| Internal targets | none |
| Emits value | no — `data-ds-state` reports status, not a user value |

```html
<button type="button" class="button button--l progress-button"
        style="--progress-button-time: 15"
        data-ds-component="progress-button"
        data-ds-component-id="offer.driver.accept"
        data-ds-preset="l"
        data-ds-variant="countdown"
        data-ds-state="standard"
        data-ds-action="tap"
        aria-describedby="offer-accept-time">
  <span class="progress-button__line" aria-hidden="true"><span class="progress-button__track"></span></span>
  <span class="button__start-text">
    <span class="button__title-row">Accept</span>
  </span>
</button>
<span id="offer-accept-time" class="visually-hidden">15 seconds remaining</span>
```

`data-ds-state` follows the live state — `standard`, `disabled`, `loading`, `skeleton` — and is
updated with the matching ARIA attribute. The end of the run is not a tap and emits nothing; the
screen's own next step is tracked where it happens.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.1.3 | 2026-11-12 | Owner ruling: the line's 1.58:1 is accepted — a screen where the deadline matters prints the seconds, making the line a supplementary picture; the track keeps its token, the subtitle row stays off by default. Status Draft → Ready (owner). |
| 3.1.2 | 2026-11-11 | Two-row heights under the SP modes: since Typography 3.6.0 (merged with Button 3.4.0) the web text scales with the scope, so with the subtitle row on `M` / `S` read 54 / 69.6 / 76.8 and `L` 56 / 72.8 / 76.8 at 100 / 130 / 150% (§8). Documentation only — no CSS change. |
| 3.1.1 | 2026-11-09 | SP families (Spacing 3.3.0, PR review): every capped or fixed value takes the family Figma binds — `var(--sp-130-sN)` for height, radius, vertical padding, the `M` slot icon and the Loader; `var(--sp-100-sN)` for horizontal padding, gaps, the slot and StartText paddings and the `Main CTA` Loader. The four component-local px lengths are removed, and ProgressButton no longer reads Button's `--button-*` ceilings. Corrected on the way: horizontal padding and gaps had scaled with the full family although §3 and the masters hold them at 100% — at 130 / 150% they now stay 12 / 4. Nothing else moves. |
| 3.1.0 | 2026-11-04 | Initial spec and web build, aligned to Figma `[ProgressButton] 3.0` with Button 3.4's scaling. Height, radius and vertical padding `cap130` on every size (radius on `M` / `S` too — the master binding, owner); height a minimum on `L` / `M` / `S` with Button's multiline, `Main CTA` fixed. Loader `cap130` on `L` / `M` / `S`, fixed 32 on `Main CTA`. The progress line: a `Surface/DarkOverlay` track with a 64° edge and a `s2` `Surface/WhiteOverlay` band, run on `transform` over `--progress-button-time` (default 15) on `--curve-linear` — a recorded exception to the primitives rule (owner); one step per second under reduced motion. `Countdown` (default) and `Countup` directions; RTL mirrors. Pressed is Button's scale-only press (owner). Figma flags recorded in §3. §8 carries the per-size padding table, and the page a per-size Layout block for each configuration. |

---

# ProgressButton — Accessibility

**Component:** ProgressButton
**Version:** 3.1.1
**Spec:** [`progress-button.md`](https://super-dollop-pzmo65r.pages.github.io/progress-button.md)

Source: the Figma Specification frame, section "Accessibility. Screen Reader" (`24583:16828`). It is
the component's default behaviour; a screen adapts it to its scenario.

---

## Role and ARIA

ProgressButton is a [Button](https://super-dollop-pzmo65r.pages.github.io/button.md) and inherits its whole contract — the role, the
`Loading` name, the slot treatment, the cut and wrapped label, focus, keyboard and target size
([`button-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/button.md)). This file records what the timer adds.

| Attribute | Value | Where |
|---|---|---|
| `type` | `"button"` | The root — a bare `<button>` in a form submits |
| `aria-describedby` | id of the time text | The root. Points at a visually hidden "N seconds remaining", which supplies the time on focus |
| `aria-hidden` | `"true"` | `.progress-button__line` — the line is a picture of the time, not a second source of it |
| `aria-live` | `"polite"` | A visually hidden region **outside** the button, which receives the timeline announcements below |
| `aria-disabled` | `"true"` | The root, once the time is up and the button is disabled |
| `aria-busy` | `"true"` | The root, in `Loading` (Button's contract). While it is set, the host empties the time text and the live region stays silent — the run is paused |

The live region stays outside the root. Text inside a `<button>` becomes part of its name, so a
region inside would rename the button every second and re-announce it whole.

The time text is updated by the host — it is a number of seconds, not a progress percentage on the
line. The line is CSS; nothing reads its position.

---

## The announcement timeline

The frame's timeline for a 15-second run. A longer run keeps the shape: one announcement at the
start, one in the middle, then each of the final five seconds.

| Time left | Announced | Channel |
|---|---|---|
| 15 s (focus) | "Accept, 15 seconds remaining, Button" | the name + `aria-describedby` — not the live region |
| 10 s | "10 seconds remaining, 66 percent" | the live region |
| 5 s | "Final 5 seconds" | the live region |
| 4, 3, 2, 1 s | "4", "3", "2", "1" | the live region |
| 0 s | "Time is up, Button disabled" | the live region |

The frame's line at 15 s is what focus produces, role included; it is not written to the live region,
where the role would be spoken as a word. The live region starts at 10 s.

The time text behind `aria-describedby` changes every second, so a person who focuses the button
mid-run hears the time actually left. The live region speaks only at the points above, so the run
does not talk over everything else on the screen.

`polite` waits for the screen reader to finish what it is saying. The final seconds land late if
something else is being read; they are not raised to `assertive`, which would cut that speech off.

---

## Android · TalkBack

`[Label]` — the title; `[Subtitle]` — the subtitle row when on; `[Time]` — "N seconds remaining".

### Tap target · the button

| Attribute | Description |
|---|---|
| **Voiced preview** | "Button. [Label], [Subtitle]. [Time]. Double tap to activate" |
| **contentDescription** | [Label] [Subtitle] [Time] |
| **Value** | — (the time is part of the description) |
| **Role** | Button |
| **Hint** | Double tap to activate |

### Edge states

- **Disabled (time is up):** *"[Label], Button, Disabled."* The time is dropped from the description; the
  last live announcement has said the time is up.
- **Loading:** *"[Label], Button, Busy."* The time stops with the line and is dropped from the
  description while the request runs.
- **Skeleton:** not exposed, as Button.

---

## iOS · VoiceOver

### Tap target · the button

| Attribute | Description |
|---|---|
| **Voiced preview** | "Button. [Label], [Subtitle]. [Time]. Double tap to activate" |
| **Label** | [Label] [Subtitle] |
| **Value** | [Time] — "15 seconds remaining" |
| **Trait** | Button, Updates Frequently |
| **Hint** | Double tap to activate |

`Updates Frequently` tells VoiceOver the value changes on its own, so it polls the value while the
button has focus instead of announcing every change. The web has no such trait; the polite live
region and the per-second description are its equivalent.

### Edge states

- **Dimmed (time is up):** `notEnabled` — VoiceOver appends "Dimmed"; the value is cleared.
- **Loading:** the label is preserved and the busy state exposed; the value is cleared while the
  request runs.
- **Skeleton:** `accessibilityElementsHidden`, as Button.

The frame gives the same voiced preview for both platforms; what differs is where the time lives.
TalkBack carries the time inside the description and VoiceOver as a value. A page point that says
"both read the time as a value" is wrong.

---

## Timing

A deadline that ends an action falls under WCAG 2.2.1 (Timing Adjustable). The 15-second default and
the 15–20 seconds on screen (spec §1) are a usability minimum, not a 2.2.1 mechanism. 2.2.1 asks the
screen for one of three: turn the limit off, adjust it to at least ten times the default, or warn and
extend it (at least 20 seconds to act, at least ten times) — unless an exception applies. The
component carries none of the three on its own; the screen that uses it either provides one or
records the exception it relies on:

- **Essential** — the limit is part of what is offered: a driver's offer expires because the driver
  is waiting and will move on. Extending it would change the activity.
- **Real-time** — the limit comes from a live event, such as a bidding round, with no alternative.

A step that would continue anyway ("Next in 15 seconds") has neither exception, so it lets a person
stop or extend the run — `.progress-button--paused` stops the line, `--progress-button-elapsed`
resumes it.

Reduced motion keeps the line and moves it in one step per second (spec §5); the announcements are
the same.

---

## Contrast

| Pairing | Note |
|---|---|
| `Background/Brand` + `TextAndIcon/AlwaysDark` | Button `Primary`'s pairing, the uncovered part of the fill |
| `Background/Brand` + `TextAndIcon/AlwaysDark` — measured | 13.4:1 — both themes, the tokens are theme-fixed |
| `Background/Brand` under `Surface/DarkOverlay` + `TextAndIcon/AlwaysDark` | The covered part. 8.5:1, both themes |
| Track (`Surface/DarkOverlay` over the brand fill) against the uncovered fill | **1.58:1** — below the 3:1 WCAG 1.4.11 asks of a graphic needed to understand content. The `s2` band against the track is 1.64:1 |
| `Surface/OnWhite` + `TextAndIcon/Disabled` | Disabled, as Button — no line |

Ratios measured on the rendered colours (sRGB, composited in the browser), 2026-11-04.

**The line fails 1.4.11.** To a screen-reader user the time is announced, but a low-vision person who
does not use one has the line and nothing else. The line passes only as a supplementary picture of a
time that is also shown as text — the seconds printed in the subtitle row. Owner ruling 2026-11-12: the
gap is accepted, and a screen where the deadline matters prints the seconds — the line is then a
supplementary picture. The track keeps its token and the subtitle row stays off by default.

---

## RTL

The line mirrors — the track grows from the left and its edge leans the other way. The announcement
order does not change; the time is read after the label in both directions.

---

## Testing checklist

- [ ] Everything in Button's checklist ([`button-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/button.md))
- [ ] The line is `aria-hidden` and never reached by swipe or Tab
- [ ] On focus the time left is read after the label, and it is the current time, not the start time
- [ ] The live region is outside the button; the button's name does not change while it speaks
- [ ] Focus at the start reads the label and "15 seconds remaining"; the live region speaks at 10, 5, 4, 3, 2, 1 and 0 seconds — and not every second before the final five
- [ ] Loading empties the time text and silences the live region until the run resumes
- [ ] The screen provides turn-off / adjust / extend, or records the 2.2.1 exception it relies on
- [ ] At 0 s: "Time is up", the button disabled, and focus kept on it
- [ ] Loading stops the line and drops the time from the announcement
- [ ] Reduced motion: the line moves once per second and the announcements are unchanged
- [ ] RTL: the line mirrors, the announcement order is the same

---

## Machine contract — `specs/components/progress-button/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": "progress-button",
  "name": "ProgressButton",
  "version": "3.1.3",
  "description": "A timer button — a Button preset with a progress line over its fill that runs for a set time (15 s by default); the screen moves on when it ends. Four configurations (Main CTA, L, M, S) and a customProgressButton base.",
  "files": {
    "spec": "specs/components/progress-button/progress-button.md",
    "a11y": "specs/components/progress-button/progress-button-a11y.md",
    "preview": "src/progress-button.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.40.9 (components)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "customProgressButton": {
        "key": "40f19a2ffafd53d96760476f2f341890786a82b1",
        "note": "The base (node 24554:7677) — a single component with Slot#1, Slot#2 and Progress Line booleans; its fill is re-pointable."
      },
      "MainCTA-ProgressButton": {
        "key": "99ac9164f819bf2d56a5f9b169f001ab3fabb370"
      },
      "L-ProgressButton": {
        "key": "abb31b6fd2012aa9d71570a2eea43c33eede76c2"
      },
      "M-ProgressButton": {
        "key": "9b8d61a70e88f2d9a39611156b1f5bc7795cc26b"
      },
      "S-ProgressButton": {
        "key": "6334021b1251f869f73ec8bbb151d010cca8de30"
      },
      "wrapper": {
        "key": "e251028621680a6397c3620356b9ecefc8410c76",
        "note": "[ProgressButton] 3.0 (node 25035:3315) — a component with one Size INSTANCE_SWAP over the four sets."
      }
    },
    "capturedAt": "2026-11-04"
  },
  "root": {
    "class": "progress-button",
    "dataDsComponent": "progress-button"
  },
  "anatomy": {
    "line": {
      "class": "progress-button__line",
      "notes": "Figma 🟡ProgressLine — an absolute layer filling the root, behind the content, aria-hidden. A size container, so the track measures the button in cqw / cqh."
    },
    "track": {
      "class": "progress-button__track",
      "notes": "The strip covering the used time: Surface/DarkOverlay with a s2 Surface/WhiteOverlay band on its slanted edge, 64° at every height. Runs by transform only."
    },
    "startSlot": {
      "class": "button__start-slot",
      "optional": true,
      "notes": "Button's slot — Figma 🔴Slot#1, off by default, absent on Main CTA."
    },
    "startText": {
      "class": "button__start-text",
      "notes": "Button's label column. On M the ProgressButton TextContainer has no vertical padding (Button's has 4)."
    },
    "titleRow": {
      "class": "button__title-row",
      "notes": "Required. Button's styles: Heading 2 on Main CTA, Heading 4 on L and the base, Main Body on M and S."
    },
    "subtitleRow": {
      "class": "button__subtitle-row",
      "optional": true,
      "notes": "Button's subtitle row; absent on Main CTA."
    },
    "endSlot": {
      "class": "button__end-slot",
      "optional": true,
      "notes": "Button's slot — Figma 🔵Slot#2."
    },
    "spinner": {
      "class": "button__spinner",
      "optional": true,
      "onlyWhen": {
        "loading": [
          true
        ]
      },
      "notes": "Button's Loader. var(--sp-130-s24) on L and the base, var(--sp-130-s16) on M and S, var(--sp-100-s32) on Main CTA."
    }
  },
  "axes": {
    "preset": {
      "title": "Preset",
      "type": "enum",
      "values": [
        "custom-progress-button",
        "main-cta",
        "l",
        "m",
        "s"
      ],
      "default": "l",
      "css": {
        "mechanism": "root .button .progress-button plus .button--cta / .button--l / .button--m / .button--s; the base is .progress-button--custom"
      },
      "figma": {
        "kind": "component-set",
        "values": {
          "custom-progress-button": "customProgressButton",
          "main-cta": "MainCTA-ProgressButton",
          "l": "L-ProgressButton",
          "m": "M-ProgressButton",
          "s": "S-ProgressButton"
        }
      },
      "constraints": [
        "The presets are locked like Button's — height, padding, gap, radius and text style do not move; a different colour is a customProgressButton with --progress-button-fill set."
      ]
    },
    "state": {
      "title": "State",
      "type": "enum",
      "values": [
        "standard",
        "disabled",
        "skeleton"
      ],
      "default": "standard",
      "css": {
        "mechanism": "standard = no modifier; disabled = .button--disabled (or :disabled); skeleton = .button--skeleton wrapping a Skeleton instance"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Disabled / Skeleton",
        "notes": "Two booleans on each set rather than one State property."
      },
      "constraints": [
        "Disabled and Skeleton carry no line and are the same on every size.",
        "Disabled is a token swap — Surface/OnWhite and TextAndIcon/Disabled — never opacity. It is the usual state once the time is up, applied by the screen."
      ]
    },
    "loading": {
      "title": "Loading",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".button--loading"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Loading",
        "values": {
          "false": "off",
          "true": "on"
        }
      },
      "constraints": [
        "The label and slots give way to the Loader; the line stays and pauses where it was.",
        "Width as Button's Loading: var(--sp-s72) on L and M, var(--sp-s64) on S; Main CTA keeps its full width."
      ]
    },
    "direction": {
      "title": "Direction",
      "type": "enum",
      "values": [
        "countdown",
        "countup"
      ],
      "default": "countdown",
      "css": {
        "mechanism": "countdown = no modifier; countup = .progress-button--countup (the run plays in reverse)"
      },
      "figma": {
        "kind": "layout",
        "notes": "Not a property — the Specification frame's 'Progress direction' section, drawn as the ProgressLine frames empty / start / middle / end / full."
      },
      "constraints": [
        "Countdown starts uncovered and ends fully covered; Countup the reverse. The button stays as it ends — the screen applies the next step."
      ]
    },
    "time": {
      "title": "Time",
      "type": "number",
      "default": 15,
      "min": 1,
      "max": 600,
      "css": {
        "mechanism": "--progress-button-time on the root, unitless seconds"
      },
      "figma": {
        "kind": "layout",
        "notes": "Specification frame 'Timing': any time, default 15 s."
      },
      "constraints": [
        "A whole number of seconds — reduced motion steps the line once per second with steps(), which takes an integer.",
        "Keep the button on screen for at least 15–20 seconds.",
        "min / max are a sanity range for an assembling agent, not a Figma limit — the frame allows any time.",
        "--progress-button-elapsed (seconds already run) resumes a run or freezes one frame; .progress-button--paused stops it."
      ],
      "step": 1
    },
    "elapsed": {
      "title": "Elapsed",
      "type": "number",
      "default": 0,
      "min": 0,
      "max": 600,
      "css": {
        "mechanism": "--progress-button-elapsed on the root — a negative animation-delay; resumes a run after a re-render or freezes one frame (with .progress-button--paused)"
      },
      "figma": {
        "kind": "layout",
        "notes": "Web-only — Figma draws the run as the ProgressLine frames."
      },
      "constraints": [
        "Not above time."
      ]
    },
    "startSlot": {
      "title": "Start slot",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "render/omit .button__start-slot"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Slot#1"
      },
      "constraints": [
        "Absent on Main CTA."
      ]
    },
    "endSlot": {
      "title": "End slot",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "render/omit .button__end-slot"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Slot#2"
      },
      "constraints": [
        "Absent on Main CTA."
      ]
    },
    "subtitle": {
      "title": "Subtitle row",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "render/omit .button__subtitle-row inside .button__start-text"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Subtitle",
        "notes": "The ↳Subtitle row of each size's TextContainer; on by default on customProgressButton."
      },
      "constraints": [
        "Absent on Main CTA. Button's style step: Compact Body on the base, L and M; Caption on S."
      ]
    },
    "titleMultiline": {
      "title": "Title multiline",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".button__title-row--multiline"
      },
      "figma": {
        "kind": "layout",
        "notes": "Not in the ProgressButton masters — taken from Button 3.4 on the owner's call (2026-11-04)."
      },
      "constraints": [
        "On L / M / S the button grows to the text; Main CTA wraps to two lines at most."
      ]
    },
    "subtitleMultiline": {
      "title": "Subtitle multiline",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".button__subtitle-row--multiline"
      },
      "figma": {
        "kind": "layout",
        "notes": "Taken from Button 3.4 with titleMultiline (owner, 2026-11-04)."
      },
      "constraints": [
        "Wherever the subtitle row is on; independent of titleMultiline."
      ]
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "dir=\"rtl\" on the root or an ancestor — the slots reverse and the line mirrors (:dir(rtl))"
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL",
        "values": {
          "false": "off",
          "true": "on"
        }
      },
      "constraints": [
        "Every size, Main CTA included."
      ]
    }
  },
  "states": {
    "root": [
      "standard",
      "pressed",
      "loading",
      "disabled",
      "skeleton"
    ]
  },
  "constraints": [
    "Scaling: each value takes the SP family Figma binds. var(--sp-130-sN) (cap130) — height (L min s56 / M min s48 / S min s40 / Main CTA fixed s64), radius s20 on all four (M and S too, unlike Button), vertical padding s8, the M slot icon s20, the Loader (s24 on L, s16 on M / S). var(--sp-100-sN) (fixed) — horizontal padding s12 (s16 on Main CTA), gaps s4 (s12 on Main CTA), slot outer padding s4, StartText padding s8, row gap s2, the Main CTA Loader s32. var(--sp-sN) (full) — the L / S slot icons. No min() caps, no component-local lengths.",
    "The line is a timer: calc(var(--progress-button-time) * 1s) on var(--curve-linear) — the primitive consumed directly, a recorded exception (owner, 2026-11-04). One step per second under reduced motion.",
    "The track fires animationend when the time is up; the button changes nothing on its own.",
    "Pressed is Button's 95% scale via pushButton — no overlay.",
    "Colours come from semantic tokens only; the two track tokens are alpha tokens and read on any fill.",
    "The DS markup is a native <button type=\"button\">; the time is announced through aria-describedby and a polite live region outside the button.",
    "The track against the uncovered fill is 1.58:1 — below WCAG 1.4.11's 3:1; it passes only as a supplementary picture of seconds also shown as text. Open owner decision (2026-11-04)."
  ],
  "analytics": {
    "dataDsComponent": "progress-button",
    "action": "tap",
    "targets": [],
    "notes": "data-ds-preset is custom-progress-button | main-cta | l | m | s; data-ds-variant is countdown | countup. The end of the run emits nothing — the screen's next step is tracked where it happens."
  },
  "rtl": {
    "supported": true,
    "notes": "dir=\"rtl\" mirrors the slots and the line (track from the left, edge leaning the other way)."
  }
}
```
