Skip to content
Dethink Components

ComponentsFeedback States

Feedback States

Show loading, progress, errors, empty states, and notifications.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files
  • Motion for React (motion) · via shared Dethink components

    Animations, transitions, and reduced-motion handling.

Shared foundation: clsx for conditional classes, React, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

Import the component into your page or component file.

Usage
import {
  Alert,
  Announcer,
  Callout,
  EmptyState,
  LiveRegionProvider,
  Progress,
  ProgressCircle,
  Skeleton,
  SkeletonAvatar,
  SkeletonButton,
  SkeletonText,
  Spinner,
  ToastProvider,
  ToastViewport,
  useAnnouncer,
  useToast,
} from "@dethink/components";

Feedback primitives compose across loading, async status, empty data, and mutation confirmation flows.

Animated loaders

Compact indicators for indeterminate activity. Every size keeps a steady footprint; reduced motion shows a static shape. Use a labelled status around visible loading text to avoid duplicate announcements.

Bouncing dot

Grows on the way up, shrinks on the way down.

xs
sm
md
lg
xl
Preparing your workspace…

Moving rings

Two concentric arcs, moving in opposite directions.

xs
sm
md
lg
xl
Syncing your records…
Show sourceexamples/feedback-states/loaders.tsx
examples/feedback-states/loaders.tsx
"use client";

import { Spinner } from "@dethink/components";

const sizes = ["xs", "sm", "md", "lg", "xl"] as const;
const loaders = [
  {
    variant: "bouncing-dot",
    title: "Bouncing dot",
    description: "Grows on the way up, shrinks on the way down.",
    status: "Preparing your workspace…",
  },
  {
    variant: "moving-rings",
    title: "Moving rings",
    description: "Two concentric arcs, moving in opposite directions.",
    status: "Syncing your records…",
  },
] as const;

export function FeedbackLoaders() {
  return (
    <div
      role="group"
      aria-label="Loader variants"
      className="grid w-full gap-4 sm:grid-cols-2"
    >
      {loaders.map(({ variant, title, description, status }) => (
        <div key={variant} className="border-border rounded-lg border p-5">
          <h3 className="text-sm font-medium">{title}</h3>
          <p className="text-muted-foreground mt-1 text-sm">{description}</p>
          <div className="mt-6 grid grid-cols-5 items-end gap-2">
            {sizes.map((size) => (
              <div key={size} className="grid justify-items-center gap-3">
                <div className="flex size-10 items-center justify-center">
                  <Spinner variant={variant} size={size} tone="primary" />
                </div>
                <span className="text-muted-foreground text-xs">{size}</span>
              </div>
            ))}
          </div>
          <div
            className="border-border mt-5 flex items-center gap-2 border-t pt-4 text-sm"
            role="status"
          >
            <Spinner variant={variant} size="sm" />
            {status}
          </div>
        </div>
      ))}
    </div>
  );
}

Loading and progress

Spinner is decorative unless labelled. Progress exposes determinate or indeterminate progressbar semantics, while Skeleton reserves layout space.

Import progress68%
72%
Show sourceexamples/feedback-states/loading.tsx
examples/feedback-states/loading.tsx
"use client";

import {
  Progress,
  ProgressCircle,
  SkeletonAvatar,
  SkeletonButton,
  SkeletonText,
  Spinner,
} from "@dethink/components";

export function FeedbackLoading() {
  return (
    <div className="grid gap-6">
      <div className="flex flex-wrap items-center gap-4">
        <Spinner label="Loading reports" tone="primary" />
        <Spinner aria-label="Refreshing" variant="dots" tone="muted" />
        <Progress label="Import progress" value={68} showValue tone="info" />
      </div>
      <div className="border-border grid gap-4 rounded-lg border p-4">
        <div className="flex items-center gap-4">
          <ProgressCircle label="Upload" value={72} showValue tone="success" />
          <div className="grid flex-1 gap-2">
            <SkeletonText lines={3} animation="shimmer" />
            <div className="flex items-center gap-3">
              <SkeletonAvatar />
              <SkeletonButton />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

Messaging and empty states

Alert carries status urgency, Callout stays contextual, and EmptyState keeps action slots predictable.

Deployment queued
The release will continue in the background.
Large imports
Files over 100 MB continue processing after this panel closes.
No failed jobs
Background jobs that need attention will appear here.
Show sourceexamples/feedback-states/messaging.tsx
examples/feedback-states/messaging.tsx
"use client";

import { AlertTriangle, CheckCircle2, Inbox, UploadCloud } from "lucide-react";
import { Alert, Button, Callout, EmptyState } from "@dethink/components";

export function FeedbackMessaging() {
  return (
    <div className="grid gap-4">
      <Alert
        icon={<CheckCircle2 />}
        title="Deployment queued"
        description="The release will continue in the background."
        tone="success"
      />
      <Alert
        icon={<AlertTriangle />}
        title="Payment retry failed"
        description="Update the payment method before retrying the invoice."
        tone="destructive"
        urgency="assertive"
      />
      <Callout
        icon={<UploadCloud />}
        title="Large imports"
        description="Files over 100 MB continue processing after this panel closes."
      />
      <EmptyState
        visual={<Inbox />}
        title="No failed jobs"
        description="Background jobs that need attention will appear here."
        primaryAction={<Button variant="outline">Refresh</Button>}
        variant="table"
      />
    </div>
  );
}

Shared announcements

LiveRegionProvider centralizes polite and assertive announcements and supports debounced result-count updates.

Polite
No result announcement sent.
Assertive
No failure announcement sent.
Show sourceexamples/feedback-states/announcer.tsx
examples/feedback-states/announcer.tsx
"use client";

import { useState } from "react";
import { Button, LiveRegionProvider, useAnnouncer } from "@dethink/components";

function AnnouncerControls() {
  const announcer = useAnnouncer();
  const [count, setCount] = useState(0);
  const [politePreview, setPolitePreview] = useState(
    "No result announcement sent.",
  );
  const [assertivePreview, setAssertivePreview] = useState(
    "No failure announcement sent.",
  );

  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap gap-2">
        <Button
          variant="outline"
          onClick={() => {
            const next = count + 1;
            const message = `${next} filtered ${next === 1 ? "result" : "results"} available`;

            setCount(next);
            setPolitePreview(message);
            announcer.announcePolite(message, {
              coalesceKey: "results",
              debounceMs: 150,
            });
          }}
        >
          Announce results
        </Button>
        <Button
          variant="destructive"
          onClick={() => {
            const message = "Connection lost";

            setAssertivePreview(message);
            announcer.announceAssertive(message);
          }}
        >
          Announce failure
        </Button>
      </div>
      <dl className="grid gap-2 sm:grid-cols-2">
        <div className="border-border bg-background/80 rounded-md border p-3 text-sm shadow-sm">
          <dt className="text-muted-foreground text-xs font-semibold tracking-[0.14em] uppercase">
            Polite
          </dt>
          <dd className="text-foreground mt-1 font-medium">{politePreview}</dd>
        </div>
        <div className="border-border bg-background/80 rounded-md border p-3 text-sm shadow-sm">
          <dt className="text-muted-foreground text-xs font-semibold tracking-[0.14em] uppercase">
            Assertive
          </dt>
          <dd className="text-foreground mt-1 font-medium">
            {assertivePreview}
          </dd>
        </div>
      </dl>
    </div>
  );
}

export function FeedbackAnnouncer() {
  return (
    <LiveRegionProvider>
      <AnnouncerControls />
    </LiveRegionProvider>
  );
}

Toast workflow

Toast uses the shared announcer, explicit dismiss controls, auto-dismiss timers, pause-on-hover/focus, and Motion-powered stack presence.

    Show sourceexamples/feedback-states/toast-workflow.tsx
    examples/feedback-states/toast-workflow.tsx
    "use client";
    
    import { AlertTriangle, CheckCircle2, ListChecks } from "lucide-react";
    import {
      Button,
      ToastProvider,
      ToastViewport,
      useToast,
    } from "@dethink/components";
    
    function ToastControls() {
      const { toast } = useToast();
    
      return (
        <div className="flex flex-wrap gap-2">
          <Button
            leftIcon={<CheckCircle2 />}
            onClick={() =>
              toast({
                action: {
                  label: "Undo",
                  onClick: () => undefined,
                },
                description: "Workspace settings were saved.",
                title: "Saved",
                tone: "success",
              })
            }
          >
            Save
          </Button>
          <Button
            leftIcon={<AlertTriangle />}
            variant="outline"
            onClick={() =>
              toast({
                description: "The import worker stopped before finishing.",
                persistent: true,
                title: "Import failed",
                tone: "destructive",
              })
            }
          >
            Fail import
          </Button>
          <Button
            leftIcon={<ListChecks />}
            variant="outline"
            onClick={() =>
              toast({
                announcement: "CSV import is ready for review.",
                render: ({ dismiss }) => (
                  <div className="grid gap-3">
                    <div className="grid gap-1">
                      <div className="text-foreground font-medium">
                        CSV import ready
                      </div>
                      <div className="text-muted-foreground">
                        42 rows matched. 3 rows need review before publishing.
                      </div>
                    </div>
                    <Button size="sm" variant="outline" onClick={dismiss}>
                      Review later
                    </Button>
                  </div>
                ),
                tone: "info",
              })
            }
          >
            Custom toast
          </Button>
        </div>
      );
    }
    
    export function FeedbackToastWorkflow() {
      return (
        <div className="min-h-72">
          <ToastProvider motion="standard">
            <ToastControls />
            <ToastViewport />
          </ToastProvider>
        </div>
      );
    }

    Feedback state primitives share token-backed styling, stable slots, and reduced-motion-safe behavior.

    Feedback state props
    PropWhat it doesDefault
    LiveRegionProvider{ renderRegions, politeClassName, assertiveClassName }Provides one polite and one assertive live-region channel for announcements.{ renderRegions: true }
    useAnnouncer() => { announce, announcePolite, announceAssertive, clear }Imperative announcement API with debounce and coalescing options.Not set
    Spinner{ size, tone, variant, label }Decorative loading indicator by default; labelled instances expose role=status.{ size: "md", tone: "current", variant: "ring" }
    Spinner.variant"ring" | "dots" | "bouncing-dot" | "moving-rings"Indeterminate loading style. Use Skeleton for content placeholders and Progress for measured completion."ring"
    Progress{ value, min, max, indeterminate, label, status, showValue, tone }Linear progressbar with determinate, indeterminate, value label, and status text support.{ min: 0, max: 100 }
    ProgressCircleProgressProps & { thickness }Circular progress visual with the same value semantics as Progress.{ thickness: 8 }
    Skeleton{ animation: "pulse" | "shimmer" | "none", radius }Decorative layout-preserving placeholder with reduced-motion-safe animation.{ animation: "pulse", radius: "md" }
    Alert{ tone, variant, urgency, title, description, icon, actions, onDismiss }Inline status surface with optional urgent announcement semantics and action slots.{ tone: "info", variant: "soft", urgency: "polite" }
    CalloutAlertPropsContextual guidance surface that stays non-urgent unless explicitly configured.{ tone: "neutral", variant: "outline", urgency: "none" }
    EmptyState{ variant, tone, visual, title, description, primaryAction, secondaryAction }Composable empty, no-results, no-access, onboarding, and recovery state layout.{ variant: "card", tone: "neutral" }
    ToastProvider{ toasts, defaultToasts, placement, motion, maxToasts, defaultDuration }Owns toast state, announcements, timers, and the imperative useToast API.{ placement: "bottom-end", motion: "standard" }
    ToastRecord.renderReactNode | ({ toast, dismiss }) => ReactNodeOverrides default title, description, and action layout; pair with announcement for live-region copy.Not set
    ToastViewport{ placement, motion }Fixed, safe-area-aware stack surface for actionable and dismissible toasts.Not set