Lunia DS

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.

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-activity

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
activityLiveActivityData | 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.
expandedboolean-Whether the panel is open, when controlled.
defaultExpandedbooleanfalseWhether the panel starts open, when not controlled.
panelWidthnumber320Width of the open panel in px; it never goes past the screen width minus 24px.
localesstring | string[]"en-US"Locale for the figures; keep one fixed value so server and browser format numbers the same way.
EventDescription
expandedChangeFires with true when the pill opens into the panel, and false when Escape or a click outside folds it back.
SlotDescription
iconPer 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
LA1activity starts (null -> activity) or is clearedsurfacewidth + transform scale0, 0.85 → pill width, 1duration-3xl (460)ease-outShows at once
LA2activity starts or is clearedsurfaceopacity0 → 1duration-md (260)ease-outShows at once
LA3pill clicked (Pill -> Panel), or Escape or a click outside (Panel -> Pill)surfacewidth + height + border-radiuspill size, radius full → panel size, radius-3xlduration-3xl (460)ease-outResizes at once
LA4Pill -> Panelpill faceopacity + filter blur1, 0 → 0, blur-textduration-xs (160)ease-outHides at once
LA5Pill -> Panelpanel faceopacity + filter blur0, blur-text → 1, 0duration-lg (320)ease-outdelay 120msShows at once
LA6Panel -> Pillpanel faceopacity + filter blur1, 0 → 0, blur-textduration-2xs (120)ease-outHides at once
LA7Panel -> Pillpill faceopacity + filter blur0, blur-text → 1, 0duration-lg (320)ease-outShows at once
LA8a different activity (new id)pill face contentopacity + filter blur + transform translateY0, 5px, distance-rise → 1, 0, 0duration-xl (380)ease-outShows at once
LA9label changes (same id)pill label + panel titletext morph (the pill label also eases its width)old label → new labelduration-3xl (460)ease-outSwaps at once
LA10progress changesprogress ringstroke-dashoffsetold progress → new progressduration-4xl (520)ease-outResizes at once
LA11progress changesprogress bar fillwidthold progress → new progressduration-4xl (520)ease-outResizes at once
LA12status becomes done or errorprogress bar fillbackground-colorbg-brand-solid → bg-success-solid or bg-error-solidduration-lg (320)ease-outSwaps at once
LA13progress changespercentnumber rollold percent → new percentduration-4xl (520)ease-outSwaps at once
LA14count changespill figure + panel figurenumber rollold value → new valueduration-5xl (640)ease-outSwaps at once
LA15status changes (Running -> Done or Error), or progress appears or endsstatus mark icons + progress ring (old one out, new one in)opacity + filter blur0, blur-swap → 1, 0duration-md (260)ease-outSwaps at once
LA16status changes (Running -> Done or Error), or progress appears or endsstatus mark icons + progress ring (old one out, new one in)transform scalescale-swap → 1duration-xl (380)ease-outSwaps at once
LA17Running -> Done (pill, panel and each finished step)checkstroke-dashoffset (draw)1 → 0duration-2xl (420)ease-outdelay 120msShows at once
LA18while Running with no icon (pill, panel and current step)spinnertransform rotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
LA19a step becomes current or donestep labelcolortext-quaternary → text-primary (current), text-tertiary (done)duration-lg (320)ease-outSwaps at once
LA20hover an action buttonaction buttonbackground-colorbg-primary or bg-brand-solid → bg-primary_hover or bg-brand-solid_hoverduration-xs (160)ease-outSwaps 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.