ComponentsFeedback

Saving status

Edits save as you make them. A small dot says a change is still on its way; words appear only when a save takes long or fails.

Playground

Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.

import { PendingDot } from '@/ui/saving-status';<PendingDot label="Saving" />

Pending dot

The 6px dot pulses while a change is in flight. muted sits in the selection column of a new row, faint beside a cell's value, warning where the wait is worth watching.

Maya ChenHalcyon BankSourcing
import { PendingDot } from '@/ui/saving-status';

export default function SavingStatusDot() {
  return (
    <div className="flex flex-wrap items-center gap-400 text-body leading-body text-text">
      <span className="flex items-center gap-100">
        <PendingDot />
        Maya Chen
      </span>
      <span className="flex items-center gap-100">
        Halcyon Bank
        <PendingDot tone="faint" />
      </span>
      <span className="flex items-center gap-100">
        Sourcing
        <PendingDot tone="warning" label="Finding people" />
      </span>
    </div>
  );
}

The dot goes when the save lands

Press Replay. No "Saved" text and no toast: the dot going is the answer.

Orbital Freight
import { useState } from 'react';
import { Button } from '@/ui/button';
import { PendingDot } from '@/ui/saving-status';

// Press Replay: the dot shows while the change is on its way and goes when it lands.
export default function SavingStatusCell() {
  const [saving, setSaving] = useState(false);
  const save = () => {
    setSaving(true);
    setTimeout(() => setSaving(false), 2400);
  };
  return (
    <div className="flex items-center gap-400">
      <span className="flex w-40 items-center gap-100 text-body leading-body text-text">
        Orbital Freight
        {saving ? <PendingDot tone="faint" /> : null}
      </span>
      <Button size="sm" onClick={save} disabled={saving}>
        Replay
      </Button>
    </div>
  );
}

Words in the toolbar

A shared View says it is saving in words, in a 160px slot at the toolbar's end so the toolbar doesn't move. Press Replay: the words fade in after 400ms, so a quick save shows nothing.

Saving shared View…
import { useState } from 'react';
import { Button } from '@/ui/button';
import { SavingStatus } from '@/ui/saving-status';

// Press Replay: a View change goes out a second later, its words fade in once the save has run 400ms, and clear when it
// lands.
export default function SavingStatusToolbar() {
  const [saving, setSaving] = useState(true);
  const replay = () => {
    setSaving(false);
    setTimeout(() => setSaving(true), 1200);
    setTimeout(() => setSaving(false), 3600);
  };
  return (
    <div className="flex w-full flex-col items-start gap-200">
      <div className="flex h-11 w-full items-center gap-100 rounded-xlarge px-150 shadow-ring">
        <Button variant="ghost">Filter</Button>
        <Button variant="ghost">Sort</Button>
        <div className="grow" />
        <SavingStatus>{saving ? 'Saving shared View…' : null}</SavingStatus>
      </div>
      <Button size="sm" onClick={replay}>
        Replay
      </Button>
    </div>
  );
}

No answer

The answer never came. The failure stays on screen with the exact fix until the person picks it.

import { Button } from '@/ui/button';
import { SaveFailure } from '@/ui/saving-status';

export default function SavingStatusFailure() {
  return <SaveFailure actions={<Button>Retry same request</Button>}>Ascern didn’t hear back, so this View may not have saved.</SaveFailure>;
}

Conflict

Someone else saved first. Offer both ways out.

import { Button } from '@/ui/button';
import { SaveFailure } from '@/ui/saving-status';

export default function SavingStatusConflict() {
  return (
    <SaveFailure
      actions={
        <>
          <Button>Use shared View</Button>
          <Button>Keep my changes for review</Button>
        </>
      }
    >
      Tom changed this View while you were editing it.
    </SaveFailure>
  );
}

Couldn't save

A save that was refused or never answered. Say what didn't save and that the edit is kept, then the fix. It stays until the person acts; never a toast that closes on its own.

import { Button } from '@/ui/button';
import { SaveFailure } from '@/ui/saving-status';

// A cell's save was refused or never answered. The edit stays in the cell.
export default function SavingStatusFailed() {
  return <SaveFailure actions={<Button>Try again</Button>}>Couldn’t save the salary. Your edit is kept; try again.</SaveFailure>;
}

Offline

The connection dropped mid-save. The dot turns to the warning tone and the line says the edit is kept. Press Try again, then Replay.

Halcyon Bank
import { useState } from 'react';
import { Button } from '@/ui/button';
import { PendingDot, SaveFailure, SavingStatus } from '@/ui/saving-status';

// The connection dropped mid-save. Press Try again: the save goes when the browser is back online.
export default function SavingStatusOffline() {
  const [state, setState] = useState<'offline' | 'saving' | 'saved'>('offline');
  const retry = () => {
    setState('saving');
    setTimeout(() => setState('saved'), 1500);
  };
  return (
    <div className="flex w-full flex-col items-start gap-200">
      <span className="flex items-center gap-100 text-body leading-body text-text">
        Halcyon Bank
        {state === 'saved' ? null : <PendingDot tone="warning" label={state === 'offline' ? 'Not saved' : 'Saving'} />}
      </span>
      {state === 'offline' ? (
        <SaveFailure actions={<Button onClick={retry}>Try again</Button>}>You’re offline, so this change didn’t save. Your edit is kept.</SaveFailure>
      ) : (
        <SavingStatus>{state === 'saving' ? 'Saving…' : null}</SavingStatus>
      )}
      {state === 'saved' ? (
        <Button size="sm" onClick={() => setState('offline')}>
          Replay
        </Button>
      ) : null}
    </div>
  );
}

On this page