Feedback and live
Live activity
One pill for the task that is running now, which opens out of nothing, updates its label, ring and figure in place, grows into a detail panel on click, and turns its spinner into a check or an alert when the task ends.
Live preview
Loading preview
An activity "Importing contacts" with progress, a count of imported rows, and three steps (Read file, Match fields, Save contacts). Progress moves from 0 to 100% over about 4 seconds while steps tick off, then the status turns to done and the label becomes "Contacts imported". Replay starts with the pill, opens the panel halfway through, folds it, lets the task finish, then clears the activity so the pill folds away. The panel actions are "Stop" while running and "View contacts" (primary) when done.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add live-activityUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { LiveActivity, type LiveActivityItem, type LiveActivityStep } from './live-activity';
const TOTAL = 4000;
function stepsAt(progress: number, done: boolean): LiveActivityStep[] {
const names = ['Read file', 'Match fields', 'Save contacts'];
const at = done ? 3 : progress < 0.3 ? 0 : progress < 0.7 ? 1 : 2;
return names.map((label, i) => ({ label, status: i < at ? 'done' : i === at ? 'current' : 'pending' }));
}
function importing(run: number, progress: number, stop: () => void): LiveActivityItem {
return {
id: `import-${run}`,
label: 'Importing contacts',
detail: 'contacts.csv, 4,000 rows',
progress,
count: Math.round(progress * TOTAL),
steps: stepsAt(progress, false),
actions: [{ label: 'Stop', onSelect: stop }],
};
}
function imported(run: number): LiveActivityItem {
return {
id: `import-${run}`,
label: 'Contacts imported',
status: 'done',
detail: 'contacts.csv, 4,000 rows',
progress: 1,
count: TOTAL,
steps: stepsAt(1, true),
actions: [{ label: 'View contacts', primary: true }],
};
}
export default function LiveActivityDemo({ replay = 0 }: { replay?: number }) {
const [activity, setActivity] = React.useState<LiveActivityItem | null>(() => importing(0, 0.62, () => {}));
const [expanded, setExpanded] = React.useState(false);
const timers = React.useRef<number[]>([]);
const clear = React.useCallback(() => {
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
}, []);
React.useEffect(() => clear, [clear]);
// Replay: a new import fills over about 4 seconds; the panel opens halfway and folds, then the pill folds away.
React.useEffect(() => {
if (replay === 0) return;
clear();
const later = (ms: number, run: () => void) => timers.current.push(window.setTimeout(run, ms));
const stop = () => {
clear();
setExpanded(false);
setActivity(null);
};
setExpanded(false);
setActivity(null);
for (let i = 0; i <= 20; i++) later(400 + i * 200, () => setActivity(importing(replay, i / 20, stop)));
later(1600, () => setExpanded(true));
later(3000, () => setExpanded(false));
later(4600, () => setActivity(imported(replay)));
later(6400, () => setActivity(null));
later(7400, () => setActivity(importing(0, 0.62, () => {})));
}, [replay, clear]);
return (
<div className="flex min-h-[340px] w-full items-start justify-center pt-4">
<LiveActivity activity={activity} expanded={expanded} onExpandedChange={setExpanded} locales="en-US" />
</div>
);
}
API
Parts: root, surface (pill or panel, one shape), pill face (button), mark slot (status mark, or progress ring while running with progress), status mark (spinner or icon, check, alert), progress ring, pill label (text morph), pill figure (number roll), panel face (group), panel status mark, panel title (text morph), panel detail line, panel figure (number roll), progress bar, steps count + percent (number roll), step list (mark + label), action buttons, live region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| activity | LiveActivityData | null | - | The current activity: { id, label, status?, progress?, count?, icon?, title?, detail?, steps?, actions? }. A new id is a different activity and blurs in; the same id updates in place; null folds the pill away. Required. |
| expanded | boolean | - | Whether the panel is open, when controlled. |
| defaultExpanded | boolean | false | Whether the panel starts open, when not controlled. |
| panelWidth | number | 320 | Width of the open panel in px; it never goes past the screen width minus 24px. |
| locales | string | string[] | "en-US" | Locale for the figures; keep one fixed value so server and browser format numbers the same way. |
| Event | Description |
|---|---|
| expandedChange | Fires with true when the pill opens into the panel, and false when Escape or a click outside folds it back. |
| Slot | Description |
|---|---|
| icon | Per activity: an icon shown in place of the spinner while running. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| LA1 | activity starts (null -> activity) or is cleared | surface | width + transform scale | 0, 0.85 → pill width, 1 | duration-3xl (460)ease-out | Shows at once |
| LA2 | activity starts or is cleared | surface | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| LA3 | pill clicked (Pill -> Panel), or Escape or a click outside (Panel -> Pill) | surface | width + height + border-radius | pill size, radius full → panel size, radius-3xl | duration-3xl (460)ease-out | Resizes at once |
| LA4 | Pill -> Panel | pill face | opacity + filter blur | 1, 0 → 0, blur-text | duration-xs (160)ease-out | Hides at once |
| LA5 | Pill -> Panel | panel face | opacity + filter blur | 0, blur-text → 1, 0 | duration-lg (320)ease-outdelay 120ms | Shows at once |
| LA6 | Panel -> Pill | panel face | opacity + filter blur | 1, 0 → 0, blur-text | duration-2xs (120)ease-out | Hides at once |
| LA7 | Panel -> Pill | pill face | opacity + filter blur | 0, blur-text → 1, 0 | duration-lg (320)ease-out | Shows at once |
| LA8 | a different activity (new id) | pill face content | opacity + filter blur + transform translateY | 0, 5px, distance-rise → 1, 0, 0 | duration-xl (380)ease-out | Shows at once |
| LA9 | label changes (same id) | pill label + panel title | text morph (the pill label also eases its width) | old label → new label | duration-3xl (460)ease-out | Swaps at once |
| LA10 | progress changes | progress ring | stroke-dashoffset | old progress → new progress | duration-4xl (520)ease-out | Resizes at once |
| LA11 | progress changes | progress bar fill | width | old progress → new progress | duration-4xl (520)ease-out | Resizes at once |
| LA12 | status becomes done or error | progress bar fill | background-color | bg-brand-solid → bg-success-solid or bg-error-solid | duration-lg (320)ease-out | Swaps at once |
| LA13 | progress changes | percent | number roll | old percent → new percent | duration-4xl (520)ease-out | Swaps at once |
| LA14 | count changes | pill figure + panel figure | number roll | old value → new value | duration-5xl (640)ease-out | Swaps at once |
| LA15 | status changes (Running -> Done or Error), or progress appears or ends | status mark icons + progress ring (old one out, new one in) | opacity + filter blur | 0, blur-swap → 1, 0 | duration-md (260)ease-out | Swaps at once |
| LA16 | status changes (Running -> Done or Error), or progress appears or ends | status mark icons + progress ring (old one out, new one in) | transform scale | scale-swap → 1 | duration-xl (380)ease-out | Swaps at once |
| LA17 | Running -> Done (pill, panel and each finished step) | check | stroke-dashoffset (draw) | 1 → 0 | duration-2xl (420)ease-outdelay 120ms | Shows at once |
| LA18 | while Running with no icon (pill, panel and current step) | spinner | transform rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| LA19 | a step becomes current or done | step label | color | text-quaternary → text-primary (current), text-tertiary (done) | duration-lg (320)ease-out | Swaps at once |
| LA20 | hover an action button | action button | background-color | bg-primary or bg-brand-solid → bg-primary_hover or bg-brand-solid_hover | duration-xs (160)ease-out | Swaps at once |
Accessibility
- The pill is a button with aria-expanded and aria-controls for the panel; it is inert while the panel is open or the activity is cleared.
- The panel is a group named by the activity title; it is inert while folded, so Tab never reaches the hidden face.
- Escape folds the panel and returns focus to the pill; a click outside folds it too.
- A visually hidden polite live region reads the label and adds ", done" or ", failed" when the status changes; it never reads the ticking figures or the percent.
- Status marks, the ring and the progress bar are aria-hidden; the label and the live region carry the meaning, so status is never shown by color alone.
- The pill is 36px tall and action buttons 32px, above the 24px minimum target; visible focus ring on each.
- Reduced motion: no spin, blur or scale; the surface resizes at once, marks swap at once, and numbers and text swap at once.