Sync Status
Feedback

Sync Status

Multi-state sync pill — synced/syncing/conflict/offline icons pop-swap, the label blur-swaps, and a grid-rows detail row folds open with a state-appropriate action.

Install

npx shadcn@latest add @paragon/sync-status

sync-status.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  Check,
  ChevronDown,
  CloudOff,
  RefreshCw,
  TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type SyncState = "synced" | "syncing" | "conflict" | "offline";

export interface SyncStatusProps
  extends Omit<React.ComponentProps<"div">, "onDrag"> {
  state?: SyncState;
  /** Items still waiting to sync — shown while syncing/offline. */
  pendingCount?: number;
  /** Detail line inside the expandable row. Defaults per state. */
  detail?: React.ReactNode;
  /** Action label inside the detail row (e.g. "Resolve", "Retry"). */
  actionLabel?: string;
  onAction?: () => void;
  defaultExpanded?: boolean;
}

const syncStyles = `
@keyframes pg-sync-spin { to { rotate: 360deg; } }
@keyframes pg-sync-pulse { 50% { opacity: 0.4; } }
`;

const stateLabel: Record<SyncState, string> = {
  synced: "Synced",
  syncing: "Syncing",
  conflict: "Conflict",
  offline: "Offline",
};

const defaultDetail: Record<SyncState, string> = {
  synced: "All changes are up to date across devices.",
  syncing: "Uploading local changes to the workspace.",
  conflict: "This page was edited in two places. Pick a version to keep.",
  offline: "Changes are stored locally and sync when you reconnect.",
};

const defaultAction: Record<SyncState, string | null> = {
  synced: null,
  syncing: null,
  conflict: "Resolve",
  offline: "Retry now",
};

/**
 * Multi-state sync pill. The icon choreographs between states through the
 * house pop-swap (a live spinner while syncing), the label blur-swaps, and
 * clicking the pill folds a detail row open via grid-template-rows with a
 * state-appropriate action. Conflict and offline tint via the semantic
 * warning/destructive tokens. State changes announce politely; the detail
 * row is a proper disclosure (aria-expanded/controls).
 */
export function SyncStatus({
  state = "synced",
  pendingCount = 0,
  detail,
  actionLabel,
  onAction,
  defaultExpanded = false,
  className,
  ...props
}: SyncStatusProps) {
  const reducedMotion = useReducedMotion();
  const [expanded, setExpanded] = React.useState(defaultExpanded);
  const detailId = React.useId();

  const icon =
    state === "syncing" ? (
      <RefreshCw
        className="size-3.5"
        style={{
          animation: reducedMotion
            ? "pg-sync-pulse 1.6s ease-in-out infinite"
            : "pg-sync-spin 1s linear infinite",
        }}
      />
    ) : state === "conflict" ? (
      <TriangleAlert className="size-3.5" />
    ) : state === "offline" ? (
      <CloudOff className="size-3.5" />
    ) : (
      <Check className="size-3.5" />
    );

  const action = actionLabel ?? defaultAction[state];
  const spring = { type: "spring", duration: 0.3, bounce: 0 } as const;

  return (
    <div
      data-slot="sync-status"
      className={cn(
        "w-full max-w-xs overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-sync-status" precedence="paragon">
        {syncStyles}
      </style>

      <button
        type="button"
        aria-expanded={expanded}
        aria-controls={detailId}
        onClick={() => setExpanded((current) => !current)}
        className="group flex h-10 w-full items-center gap-2 px-3 text-left outline-none transition-colors duration-150 hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
      >
        <span
          aria-hidden
          className={cn(
            "flex size-6 shrink-0 items-center justify-center rounded-full transition-colors duration-150",
            state === "synced" && "bg-success/10 text-success",
            state === "syncing" && "bg-secondary text-secondary-foreground",
            state === "conflict" && "bg-warning/15 text-warning",
            state === "offline" && "bg-secondary text-muted-foreground",
          )}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={state}
              initial={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
              }
              transition={spring}
              className="flex items-center justify-center"
            >
              {icon}
            </motion.span>
          </AnimatePresence>
        </span>

        <span
          role="status"
          aria-live="polite"
          className="flex min-w-0 flex-1 items-baseline gap-1.5"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={state}
              initial={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, y: 4, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, y: -4, filter: "blur(4px)" }
              }
              transition={spring}
              className="truncate text-sm font-medium"
            >
              {stateLabel[state]}
            </motion.span>
          </AnimatePresence>
          {pendingCount > 0 && state !== "synced" && (
            <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
              {pendingCount} pending
            </span>
          )}
        </span>

        <ChevronDown
          aria-hidden
          className={cn(
            "size-4 shrink-0 text-muted-foreground transition-[rotate] duration-200 ease-[var(--ease-out)]",
            expanded && "-rotate-180",
          )}
        />
      </button>

      <div
        id={detailId}
        className={cn(
          "grid transition-[grid-template-rows,opacity] duration-200 ease-[var(--ease-out)] motion-reduce:transition-[opacity]",
          expanded
            ? "grid-rows-[1fr] opacity-100"
            : "grid-rows-[0fr] opacity-0",
        )}
      >
        <div className="overflow-hidden" inert={expanded ? undefined : true}>
          <div className="flex items-start gap-2 border-t px-3 py-2.5">
            <p className="min-w-0 flex-1 text-[13px] leading-5 text-muted-foreground">
              {detail ?? defaultDetail[state]}
            </p>
            {action && (
              <button
                type="button"
                onClick={onAction}
                className={cn(
                  "pressable relative shrink-0 rounded-md px-1.5 py-0.5 text-[13px] font-medium outline-none transition-colors duration-150",
                  "after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-full after:min-w-10 after:-translate-x-1/2 after:-translate-y-1/2",
                  "focus-visible:ring-2 focus-visible:ring-ring",
                  state === "conflict"
                    ? "text-warning hover:bg-warning/10"
                    : "text-foreground hover:bg-accent",
                )}
              >
                {action}
              </button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}