Loading indicator
components specs/components/loading-indicator.kmd
Material 3 Expressive Loading indicator — distinct from Progress indicators. Morphing shape (Cookie → Burst → Flower → Cookie cycle) via spring physics. Used for actions < 5s; replaces most indeterminate circular spinners. Integrates with pull-to-refresh.
When this spec applies
Primary triggers
- Display short-running indeterminate progress
All triggers
- Action takes < 5s with no progress estimation
- Pull-to-refresh gesture
- Replace existing indeterminate circular spinner in Koder app
Specification body
Spec — Loading indicator
Companion:
progress-indicators.kmdcobre determinate + indeterminate progresso de longo prazo. Esta spec cobre loading short < 5s com shape morphing — pattern Expressive.
Princípios
- Short by contract — < 5s. Above that → Progress indicator (cross-link).
- Shape morph signature — cycles through
shape-library.kmdshapes; NOT a spinning circle. - Spring-driven — uses
motion.kmdR9 spring tokens; NOT duration-based. - Composable — primary consumer pra pull-to-refresh + button loading state.
R1 — When to use
| Use case | Use Loading indicator? |
|---|---|
| Click "Save", wait < 5s | YES |
| Pull-to-refresh feed | YES |
| Long upload (> 5s, progress known) | NO — use Progress (determinate) |
| Indeterminate > 5s, no progress | NO — use Progress (indeterminate) |
| Background sync (invisible to user) | NO — toast/snackbar only |
| Pre-action loading (button) | YES — replaces button content briefly |
Decision tree formalized:
Action duration estimable?
├── YES: use Progress (determinate, %)
└── NO:
├── < 5s expected → Loading indicator (this spec)
└── ≥ 5s expected → Progress (indeterminate)
R2 — Shape morph cycle
Default morph sequence (per shape-library.kmd):
Cookie-4 → Cookie-7 → Burst → Flower → Cookie-4 → ...
Each transition driven by motion-spatial-default spring (per motion.kmd R9.1). Cycle duration: ~1.2s for full loop (3 morphs × 400ms each).
Configurable per consumer:
| Context | Cycle |
|---|---|
| Default (pull-to-refresh, button) | Cookie-4 → Burst → Flower → Cookie-4 |
| Compact (in chip, inline) | Cookie-4 → Cookie-7 only (faster) |
| Hero (full-screen) | Full library traversal (longer perceived) |
R3 — Sizes + colors
| Size token | Diameter (dp) | Stroke width | Use |
|---|---|---|---|
sm | 24 | 2 | Inline (button, chip) |
md | 40 | 3 | Pull-to-refresh, dialog |
lg | 64 | 4 | Hero/empty state |
Color: primary color role per themes/color-roles.kmd. Per-state override:
| State | Color override |
|---|---|
| Default | primary |
| Disabled context | text-muted |
| Error retry context | error |
R4 — Pull-to-refresh integration
When hosted in pull-to-refresh (cross-link future pull-to-refresh.kmd #075):
- Drag distance < threshold (40dp): static
Cookie-4(smaller scale 0.6). - Drag distance ≥ threshold: morph begins (continuous).
- Release < threshold: snap-back spring; indicator disappears.
- Release ≥ threshold: refresh triggers; indicator continues cycling until completion.
R5 — Button loading state
When button enters loading (per buttons.kmd):
- Replace button content (text + icon) with Loading indicator (size sm).
- Maintain button bounds (no width jump).
- Button disabled (no double-tap).
- On completion: replace back to original content via cross-fade (
motion-effect-fast).
R6 — Surface bindings
| Surface | API |
|---|---|
| Flutter | KoderLoadingIndicator({size, color}) em koder_kit/lib/src/ai/ (futuro) OR koder_kit/lib/src/loading/ |
| Web | <koder-loading-indicator size="md"> em koder_web_kit |
| Compose Android | KoderLoadingIndicator via koder-design-compose (futuro) |
| SwiftUI iOS | idem via koder-design-swift (futuro) |
| CLI / TUI | n/a (terminal usa spinner ASCII canônico) |
R7 — Reduced-motion
prefers-reduced-motion: reduce:
- Morph cycle disabled.
- Static shape (default
Cookie-4) with opacity pulse 0.5↔1.0 over 1.2s (visual "still active" signal). - Pull-to-refresh: skip drag-distance-driven morph; show static indicator on threshold cross.
R8 — Acessibilidade
- Container:
role="status" aria-live="polite" aria-label="Loading"(i18n). - After done: live region announces "Done" (configurable per context).
- Cursor: pointer if interactive (rare; cancel button siblings).
- Visual ≥ 24dp minimum (sm).
R9 — i18n
| Key | en-US | pt-BR |
|---|---|---|
loading.label.default | "Loading" | "Carregando" |
loading.label.refreshing | "Refreshing" | "Atualizando" |
loading.label.saving | "Saving" | "Salvando" |
loading.label.done | "Done" | "Concluído" |
R10 — Per-preset variation
| Preset | Loading indicator behavior |
|---|---|
material3 / material_expressive | Default (shape morph) |
material2 | Circular spinner (no morph) — fallback to old Material progress |
terminal_classic | ASCII spinner ` |
brutalist | Square block 100% color toggle (no curves) |
cyberpunk_neon | Default + glow halo |
minimalist_mono | Single thin line scaling 0% → 100% width |
glassmorphism | Default + backdrop blur ring |
T-suite
- T1 Mount: render with size md → Cookie-4 shape visible.
- T2 Morph cycle: advance time 1.2s → cycles through Cookie-4 → Burst → Flower → Cookie-4.
- T3 Sizes: render sm/md/lg → diameter 24/40/64dp; stroke 2/3/4dp.
- T4 Color override: render with
errorrole → red. - T5 Pull-to-refresh integration: drag-threshold cross → morph begins; release → snap-back smooth.
- T6 Button loading: button enters loading → content replaced; bounds preserved.
- T7 Reduced-motion: animations disabled → opacity pulse only; aria-live announces.
- T8 A11y: aria-live "polite"; aria-label correct in active locale.
- N1 Long action (>5s): policy violation lint should warn — Loading indicator NOT for > 5s use cases.
Cross-link
- Sibling:
progress-indicators.kmd - Drivers:
motion.kmdR9,shape-library.kmdR2 - Consumers:
buttons.kmd(button loading state), futurepull-to-refresh.kmd(#075) - Color:
color-roles.kmd - Refs: M3 Loading indicator https://m3.material.io/components/loading-indicator/overview
Requirements (testable)
Requirement: Short-by-contract usage {#req-loading-short-by-contract}
The Loading indicator SHALL be used only for indeterminate actions expected to complete in under 5 seconds; for actions with a known estimable duration, or indeterminate actions expected to take 5 seconds or longer, a Progress indicator SHALL be used instead and the Loading indicator SHALL NOT be selected.
Scenario: Short indeterminate action selects the Loading indicator
- GIVEN an action with no progress estimation expected to finish in under 5 seconds (e.g. click "Save")
- WHEN the component is chosen for the loading state
- THEN the Loading indicator is rendered
Scenario: Long or estimable action rejects the Loading indicator
- GIVEN an action expected to take 5 seconds or longer, or whose duration is estimable as a percentage
- WHEN a developer wires a Loading indicator to it
- THEN a policy-violation lint warns that the Loading indicator is not for >= 5s use cases
- AND the guidance directs the developer to a Progress indicator (determinate or indeterminate)
Requirement: Shape-morph signature {#req-loading-shape-morph}
The Loading indicator SHALL render as a morphing shape that cycles through the shape-library sequence Cookie-4 → Burst → Flower → Cookie-4 driven by the motion-spatial-default spring, and SHALL NOT render as a spinning circle in the default (Material 3 Expressive) preset.
Scenario: Initial mount shows the base shape
- GIVEN a Loading indicator with size md and the default preset
- WHEN the component mounts
- THEN the Cookie-4 shape is visible
- AND no rotating circular spinner is drawn
Scenario: Cycle morphs through the shape sequence
- GIVEN a mounted Loading indicator in the default preset
- WHEN animation time advances by the full ~1.2s loop
- THEN the rendered shape transitions Cookie-4 → Burst → Flower → Cookie-4
- AND each transition is spring-driven (motion-spatial-default), not duration-based
Requirement: Size and color tokens {#req-loading-size-color}
The Loading indicator SHALL honor the sm/md/lg size tokens with diameters 24/40/64 dp and stroke widths 2/3/4 dp respectively, and SHALL paint with the primary color role by default while applying the mapped override (text-muted disabled, error retry) when a state context is supplied.
Scenario: Each size token maps to its diameter and stroke
- GIVEN a Loading indicator
- WHEN it is rendered with size
sm, thenmd, thenlg - THEN the diameters measure 24 dp, 40 dp, and 64 dp respectively
- AND the stroke widths measure 2 dp, 3 dp, and 4 dp respectively
Scenario: Error-retry context overrides the color role
- GIVEN a Loading indicator placed in an error-retry context
- WHEN it renders
- THEN it paints with the
errorcolor role instead ofprimary
Requirement: Button loading state {#req-loading-button-state}
When a button enters its loading state, it SHALL replace its content (text and icon) with a size-sm Loading indicator while preserving the button's original bounds, SHALL disable the button to prevent double activation, and on completion SHALL cross-fade back to the original content using motion-effect-fast.
Scenario: Entering loading replaces content without resizing
- GIVEN a button with text and icon at a fixed width
- WHEN the button enters its loading state
- THEN its content is replaced by a size-
smLoading indicator - AND the button's width and height are unchanged
- AND the button is disabled so a second tap is ignored
Scenario: Completion restores original content
- GIVEN a button showing the Loading indicator
- WHEN the action completes
- THEN the original text and icon are restored via a motion-effect-fast cross-fade
- AND the button becomes enabled again
Requirement: Pull-to-refresh drag behavior {#req-loading-pull-to-refresh}
When hosted in a pull-to-refresh gesture, the Loading indicator SHALL remain a static scaled-down Cookie-4 below the drag threshold, SHALL begin its continuous morph once the drag distance reaches the threshold, and on release SHALL snap back and disappear if under threshold or continue cycling until refresh completion if at or above threshold.
Scenario: Drag past threshold starts the morph
- GIVEN a pull-to-refresh host with a 40 dp threshold
- WHEN the drag distance is below 40 dp
- THEN a static Cookie-4 is shown at 0.6 scale
- WHEN the drag distance reaches or exceeds 40 dp
- THEN the shape morph begins and animates continuously
Scenario: Release below threshold snaps back
- GIVEN an in-progress pull-to-refresh drag below threshold
- WHEN the gesture is released
- THEN a snap-back spring plays and the indicator disappears
- AND no refresh is triggered
Requirement: Reduced motion and accessibility {#req-loading-a11y}
The Loading indicator SHALL expose an accessible live status (role="status", aria-live="polite", localized aria-label) with a minimum visual size of 24 dp, and under prefers-reduced-motion: reduce it SHALL disable the shape-morph cycle and instead show a static Cookie-4 with a 0.5↔1.0 opacity pulse over 1.2 s.
Scenario: Active indicator announces politely with a localized label
- GIVEN a Loading indicator active in the current locale
- WHEN it is mounted
- THEN its container carries
role="status"andaria-live="polite" - AND its
aria-labelreads the loading label for the active locale ("Loading" in en-US, "Carregando" in pt-BR) - AND its rendered size is at least 24 dp
Scenario: Reduced motion replaces morph with an opacity pulse
- GIVEN the environment reports
prefers-reduced-motion: reduce - WHEN the Loading indicator is active
- THEN the shape-morph cycle is not animated
- AND a static Cookie-4 pulses opacity between 0.5 and 1.0 over 1.2 s
References
specs/themes/motion.kmdspecs/themes/shape-library.kmdspecs/components/progress-indicators.kmd