A list of active sessions with device, agent, and location; the current session is tagged, and revoking a session sweeps its row out as the rest settle up.
npx shadcn@latest add @paragon/session-device-list"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Laptop, Loader2, Smartphone, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
export type DeviceKind = "laptop" | "phone" | "desktop";
export interface DeviceSession {
id: string;
kind: DeviceKind;
/** Browser + OS, e.g. "Chrome · macOS". */
agent: string;
location: string;
/** Relative last-active label; "current" flags the active session. */
lastActive: string;
current?: boolean;
}
export interface SessionDeviceListProps extends React.ComponentProps<"div"> {
sessions?: DeviceSession[];
/** Called after a row is revoked (post-animation). */
onRevoke?: (id: string) => void;
}
const deviceIcon: Record<DeviceKind, React.ElementType> = {
laptop: Laptop,
phone: Smartphone,
desktop: Monitor,
};
const DEFAULT_SESSIONS: DeviceSession[] = [
{
id: "cur",
kind: "laptop",
agent: "Chrome · macOS",
location: "San Francisco, US",
lastActive: "Active now",
current: true,
},
{
id: "s2",
kind: "phone",
agent: "Safari · iOS",
location: "San Francisco, US",
lastActive: "12 minutes ago",
},
{
id: "s3",
kind: "desktop",
agent: "Firefox · Windows",
location: "Austin, US",
lastActive: "3 days ago",
},
{
id: "s4",
kind: "laptop",
agent: "Chrome · Linux",
location: "Berlin, DE",
lastActive: "1 week ago",
},
];
/**
* A list of active sessions. Each row shows the device, agent, and location;
* the current session is tagged and can't be revoked. Revoking a row shows a
* brief pending spinner, then the row sweeps out (opacity + x + blur — pure
* transforms) while `popLayout` lets the remaining rows settle up in the same
* beat. Revocations are announced to screen readers, and reduced motion drops
* the sweep to a plain removal.
*/
export function SessionDeviceList({
sessions = DEFAULT_SESSIONS,
onRevoke,
className,
...props
}: SessionDeviceListProps) {
const reduced = useReducedMotion();
const [list, setList] = React.useState(sessions);
const [pending, setPending] = React.useState<Set<string>>(() => new Set());
const [announcement, setAnnouncement] = React.useState("");
const timers = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
React.useEffect(() => {
return () => timers.current.forEach(clearTimeout);
}, []);
const revoke = (id: string) => {
setPending((prev) => new Set(prev).add(id));
timers.current.push(
setTimeout(() => {
setList((prev) => {
const session = prev.find((s) => s.id === id);
if (session) setAnnouncement(`${session.agent} session revoked`);
return prev.filter((s) => s.id !== id);
});
setPending((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
onRevoke?.(id);
}, 450),
);
};
const row = (session: DeviceSession) => {
const Icon = deviceIcon[session.kind];
const isPending = pending.has(session.id);
return (
<div className="flex items-center gap-3 px-4 py-3">
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
session.current
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-[18px]" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="truncate text-[13px] font-medium"
title={session.agent}
>
{session.agent}
</span>
{session.current && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-success/12 px-1.5 py-0.5 text-[10px] font-medium text-success">
<span aria-hidden className="size-1.5 rounded-full bg-success" />
This device
</span>
)}
</div>
<p
className="truncate text-xs text-muted-foreground"
title={`${session.location} · ${session.lastActive}`}
>
{session.location} · {session.lastActive}
</p>
</div>
{!session.current && (
<button
type="button"
disabled={isPending}
onClick={() => revoke(session.id)}
className={cn(
"pressable inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium whitespace-nowrap",
"text-destructive transition-colors duration-(--duration-fast) hover:bg-destructive/10",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-70",
)}
>
{isPending ? (
<>
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
Revoking
</>
) : (
"Revoke"
)}
</button>
)}
</div>
);
};
const empty = (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium">No active sessions</p>
<p className="mt-1 text-xs text-muted-foreground">
Sessions appear here when a device signs in.
</p>
</div>
);
return (
<div
data-slot="session-device-list"
className={cn(
"w-full max-w-lg divide-y overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<span aria-live="polite" role="status" className="sr-only">
{announcement}
</span>
{list.length === 0 ? (
empty
) : reduced ? (
list.map((session) => <div key={session.id}>{row(session)}</div>)
) : (
<AnimatePresence initial={false} mode="popLayout">
{list.map((session) => (
<motion.div
key={session.id}
layout
initial={false}
exit={{ opacity: 0, x: 32, filter: "blur(4px)" }}
transition={{
layout: { type: "spring", duration: 0.35, bounce: 0 },
duration: 0.18,
ease: [0.4, 0, 1, 1],
}}
>
{row(session)}
</motion.div>
))}
</AnimatePresence>
)}
</div>
);
}