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.
npx shadcn@latest add @paragon/sync-status"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>
);
}