A timer button: a Button preset with a progress line over its fill. The line runs for a set time — 15 seconds by default — and the screen moves on when it ends.
Overview
ProgressButton is an action with a deadline — accept an offer, confirm a driver, keep a search running. The time left is a line that moves across the button; when it ends, the screen decides what happens next. It is built on Button: the same four configurations, slots, rows and 3.4 scaling, plus the line. Shown frozen six seconds into a 15-second run.
-
1
Main CTA
-
2
L
-
3
M
-
4
S
-
5
customProgressButton
States
Pressed is Button's press — a 95% scale, no overlay (Animation). Loading keeps the line and stops it where it was. Disabled and Skeleton carry no line and are the same on every size; Disabled is the usual state once the time is up. Loading and Skeleton take Button's fixed widths — var(--sp-s72) on L and M, var(--sp-s64) on S.
-
1
Standard Fill var(--background-brand) (--progress-button-fill); label, icons and Loader var(--text-and-icon-always-dark); track var(--surface-dark-overlay), edge band var(--surface-white-overlay).
-
2
Loading Standard's colours; the Loader replaces the label and slots, and the line pauses.
-
3
Disabled Fill var(--surface-on-white), label and icons var(--text-and-icon-disabled); no line. A token swap, never opacity.
-
4
Skeleton The Skeleton component — var(--skeleton-on-white); no line.
The line over a Countdown
-
1
Uncovered The run starts with the brand fill whole — Figma's
fullframe. -
2
Running The track grows from the end side. Its cover is var(--surface-dark-overlay), its slanted edge carries a var(--sp-s2) band in var(--surface-white-overlay).
-
3
Two-thirds Figma's five line frames — full, start, middle, end, empty — are points of one movement; the web draws every point between them.
-
4
Time is up Fully covered — Figma's
empty. The button stays as it is and the screen applies the next step.
Anatomy
The root carries the Button classes — .button .button--{size} — plus .progress-button. Everything but the line is Button's.
-
1
Root
.progress-button— the focusable, clickable element. Clips the line to its radius. -
2
ProgressLine
.progress-button__line— an absolute layer behind the content, holding the.progress-button__track. Hidden from assistive technology. -
3
StartSlot · StartText · EndSlot Button's slots and rows, above the line. Both slots are off by default and absent on Main CTA.
Layout
Width hugs the content; height has a minimum per size and grows only with wrapped text (Button 3.4). The redlines are drawn from the rendered button, at 100%. Each value takes the SP family Figma binds: var(--sp-130-sN) for height, radius and vertical padding (they stop at 130%), var(--sp-100-sN) for horizontal padding and the gaps (they hold at 100%).
customProgressButton
-
General The base — slots, rows and fill are open. Unlike customButton it keeps the preset spacing: s12 padding and s4 gap, not s16 and s12.var(--sp-130-s56) — minimum height, cap130: 56 / 72.8 / 72.8. The same as L; a wrapped label grows it.var(--sp-130-s8) — vertical padding, cap130: 8 / 10.4 / 10.4. What stays above and below a wrapped label.var(--sp-100-s12) — padding on the root, fixed, both sides. A side ends at 16 or 20 — see L, 3.var(--sp-100-s4) — gap, fixed, between StartSlot / StartText / EndSlot.var(--sp-130-s20) — roundness, cap130: 20 / 26 / 26.Fill --progress-button-fill — var(--background-brand) by default; the one thing the base recolours.Hug — by default. Centre / centre — the content on both axes.
-
1
StartSlot / EndSlot On by default — both (Figma
Slot#1/Slot#2). The presets turn them off.var(--sp-s24) — the IconContainer inside. Hug — the slot takes its size.var(--sp-100-s4) — outer padding, fixed, toward the button edge. -
2
TextContainer var(--sp-100-s8) — horizontal padding, fixed; 0 vertical.var(--sp-100-s2) — gap, fixed, between Title and Subtitle.Heading 4 and Compact Body, both rows on by default.
-
3
ProgressLine Fills the root edge to edge, behind the content — no padding of its own. Figma
Progress Lineboolean, on by default; it clips to the root's radius.
Main CTA
-
General Locked, all of it. A shape these numbers do not cover is built from customProgressButton.var(--sp-130-s64) — height, fixed, cap130: 64 / 83.2 / 83.2. It does not grow with content, even with multiline on.var(--sp-130-s8) — vertical padding, cap130: 8 / 10.4 / 10.4. Button's Main CTA has none.var(--sp-100-s16) — padding, fixed, the same on both sides. One number, since nothing stands beside the label.var(--sp-100-s12) — gap, fixed, bound in Figma but with nothing to separate: Main CTA holds one row.var(--sp-130-s20) — roundness, cap130: 20 / 26 / 26.Fill — by default; changeable to hug. Centre / centre.
-
1
TextContainer Heading 2 — PP Agrandir, cap130. One row, no subtitle.One line, truncating to an ellipsis. Multiline wraps it to two lines at most.Loader var(--sp-100-s32) — a fixed 32 in Loading.
L
-
General Locked, all of it. Height, padding, gap, roundness and both text styles are fixed by the preset.var(--sp-130-s56) — minimum height, cap130: 56 / 72.8 / 72.8. A wrapped label grows it.var(--sp-130-s8) — vertical padding, cap130: 8 / 10.4 / 10.4.var(--sp-100-s12) — padding on the root, fixed. What a side ends up at is 16 or 20 — see 3.var(--sp-100-s4) — gap, fixed, between StartSlot / StartText / EndSlot.var(--sp-130-s20) — roundness, cap130: 20 / 26 / 26.Hug — by default; changeable to fill. Centre / centre.
-
1
StartSlot / EndSlot Off by default — both.var(--sp-s24) — the IconContainer inside, full scaling.var(--sp-100-s4) — outer padding, fixed, toward the button edge.
-
2
TextContainer var(--sp-100-s8) — horizontal padding, fixed; 0 vertical.var(--sp-100-s2) — gap, fixed, between Title and Subtitle.Heading 4 and Compact Body. Subtitle off by default; one line each, multiline opt-in per row.Loader var(--sp-130-s24) in Loading, cap130.
-
3
Padding by what the side ends in 16 — a side that ends in a slot. var(--sp-100-s12) on the root plus var(--sp-100-s4) on the slot — both fixed, so the 16 holds in every mode. An icon carries its own air.20 — a side that ends in a label. var(--sp-100-s12) on the root plus var(--sp-100-s8) on StartText. Text runs to its own edge, so it needs more.The same on M and S — the paddings are Button's.
M
-
General Locked, all of it.var(--sp-130-s48) — minimum height, cap130: 48 / 62.4 / 62.4.var(--sp-130-s8) — vertical padding, cap130: 8 / 10.4 / 10.4. On the root — Button's M has none there and puts a fixed 4 on StartText instead. So with the subtitle on, M grows past its minimum: 20 + 2 + 16 + 2 × 8 = 54 at 100% (Button's M stays at 48). The text scales with SP, so the two rows read 54 / 69.6 / 76.8 at 100 / 130 / 150%.var(--sp-100-s12) — padding on the root, fixed; var(--sp-100-s4) — gap, both fixed.var(--sp-130-s20) — roundness, cap130: 20 / 26 / 26. Where Button holds M at a fixed 20.Hug — by default. Centre / centre.
-
1
StartSlot / EndSlot Off by default — both.var(--sp-130-s20) — the IconContainer inside, cap130.var(--sp-100-s4) — outer padding, fixed, toward the button edge.
-
2
TextContainer var(--sp-100-s8) — horizontal padding, fixed; 0 vertical.var(--sp-100-s2) — gap, fixed, between Title and Subtitle.Main Body and Compact Body.Loader var(--sp-130-s16) in Loading, cap130.
S
-
General Locked, all of it.var(--sp-130-s40) — minimum height, cap130: 40 / 52 / 52. Below the 48 target at 100% — for dense contexts.var(--sp-130-s8) — vertical padding, cap130: 8 / 10.4 / 10.4. With the subtitle on, S grows past its minimum: 20 + 2 + 16 + 2 × 8 = 54 at 100%; the text scales with SP, so the two rows read 54 / 69.6 / 76.8 at 100 / 130 / 150%.var(--sp-100-s12) — padding on the root, fixed; var(--sp-100-s4) — gap, both fixed.var(--sp-130-s20) — roundness, cap130: 20 / 26 / 26. At 40 tall it reads as a pill.Hug — by default. Centre / centre.
-
1
StartSlot / EndSlot Off by default — both.var(--sp-s16) — the IconContainer inside, full scaling.var(--sp-100-s4) — outer padding, fixed, toward the button edge.
-
2
TextContainer var(--sp-100-s8) — horizontal padding, fixed; 0 vertical.var(--sp-100-s2) — gap, fixed, between Title and Subtitle.Main Body and Caption — the subtitle drops to the 12 floor.Loader var(--sp-130-s16) in Loading, cap130.
Scaling — L / M / S
-
1
100% Minimum height var(--sp-130-s56) / var(--sp-130-s48) / var(--sp-130-s40), radius var(--sp-130-s20), vertical padding var(--sp-130-s8) — 56 / 48 / 40, 20, 8.
-
2
130% 72.8 / 62.4 / 52, radius 26, vertical padding 10.4 — the family's ceiling. M and S cap their radius here too, where Button holds a fixed 20. Horizontal padding var(--sp-100-s12) and gap var(--sp-100-s4) stay 12 and 4.
-
3
150% The same as 130% — var(--sp-130-sN) stops there. The Loader too (var(--sp-130-s24), var(--sp-130-s16)); Main CTA's is var(--sp-100-s32), a fixed 32.
The slanted edge
-
1
64°, at every height A taller button gets a longer edge, not a steeper one. The line is its own size container, so the track measures the button in
cqhand keeps the angle. Not customisable. -
2
The band var(--sp-s2) of var(--surface-white-overlay) on the edge only. Both overlays are alpha tokens, so they read on any fill — the brand fill here, a custom one on customProgressButton via --progress-button-fill.
RTL
-
1
LTR The track grows from the right.
-
2
RTL Progress is mirrored: the track grows from the left and its edge leans the other way. Set
dir="rtl"on the root or an ancestor.
Animation
The line is a timer, not a transition. Shown here as a three-second run on a loop; the default is 15, and a real run plays once.
The run
-
1
Countdown — default Starts uncovered; the track grows until it covers the button. calc(var(--progress-button-time) * 1s) on var(--curve-linear) — Figma's linear, 0.25 / 0.25 / 0.75 / 0.75. The primitive is consumed directly, a recorded exception: no DS preset covers a run measured in seconds.
-
2
Countup
.progress-button--countup— the same run backwards: starts covered, ends with the fill whole. -
Compositor only The track moves by
transformalone, never resizing a box. It firesanimationendwhen the time is up; the screen listens and applies the next step. -
Reduced motion The line is kept — it is the time left — and moves one step per second: steps(var(--progress-button-time), end).
Press and Loading
-
1
Rest 100%.
-
2
Press — pushButton var(--component-push-button-press), 100% → 95%, as Button. No overlay: the Specification frame's 50% PressedOverlay is not built (owner, 2026-11-04).
-
3
Loading The Loader spins on var(--pattern-spin); the line pauses where it was.
Usage
Use ProgressButton for an offer or a choice that expires on its own, or for a step that continues by itself unless the person acts. An action with no deadline is a Button; progress of a task the person waits for (an upload, a search) is a progress indicator — the time is not theirs to use.
-
1
One timer per screen Its alternative, such as Decline, is a plain Button beside it.
-
2
No second timer Two running lines ask a person to track two deadlines at once.
-
3
Time to read 15 seconds by default; keep the button on screen for at least 15–20 seconds so a person can read what they are accepting. Below that the line pressures rather than informs.
Accessibility
ProgressButton inherits Button's whole contract — role, Loading name, slots, focus, keyboard, target size. The specimen is a real <button>: Start runs a shortened, six-second run — the frame's timeline from its final five seconds (a 15-second run adds the 10-second announcement) — and the caption under it is the live region — shown here on purpose; in a product it carries .visually-hidden. The per-platform tables live in specs/components/progress-button/progress-button-a11y.md.
Screen reader
-
1
One Button, the Time as Its Description TalkBack reads the time inside the description —
contentDescription: [Label] [Subtitle] [Time]. "Button. Accept, Courier. 15 seconds remaining. Double tap to activate."VoiceOver reads it as a value, with the traits Button and Updates Frequently. The web's equivalent:aria-describedbyon a visually hidden "N seconds remaining", updated every second.The line isaria-hidden="true". It is a picture of the time, not a second source of it. -
The Announcement Timeline 15 s on focus, 10 s ("10 seconds remaining, 66 percent"), 5 s ("Final 5 seconds"), then 4, 3, 2, 1, and 0 s ("Time is up, Button disabled").Spoken by a
aria-live="polite"region outside the button. Text inside a <button> becomes its name — a region inside would rename and re-announce it every second. -
Edge States Time is up: TalkBack "[Label], Button, Disabled"; VoiceOver appends "Dimmed" (
notEnabled). The time leaves the description on TalkBack and the value is cleared on VoiceOver. Focus stays on the button —aria-disabled, notdisabled.Loading: TalkBack "[Label], Button, Busy"; VoiceOver keeps the label and exposes busy. The line pauses; the host empties the time text and the live region stays silent.Skeleton: not exposed on either platform. -
Timing WCAG 2.2.1 Timing Adjustable. 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: essential (a driver's offer expires because the driver moves on) or real-time.Reduced motion keeps the line, one step per second; the announcements are the same.The line is 1.58:1 against the uncovered fill — below the 3:1 of WCAG 1.4.11. It passes only beside the seconds shown as text, in the subtitle row — so a screen where the deadline matters prints the seconds; the gap is accepted (owner, 2026-11-12).