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 collapses and sweeps out
* (height via layout, opacity + x-translate for the sweep) while the rest
* settle up. 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 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) => 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">
{session.agent}
</span>
{session.current && (
<span className="inline-flex items-center gap-1 rounded-full bg-success/12 px-1.5 py-0.5 text-[10px] font-medium text-success">
<span className="size-1.5 rounded-full bg-success" />
This device
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">
{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>
);
};
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}
>
{reduced ? (
list.map((session) => (
<div key={session.id}>{row(session)}</div>
))
) : (
<AnimatePresence initial={false}>
{list.map((session) => (
<motion.div
key={session.id}
layout
initial={false}
exit={{
opacity: 0,
x: 40,
height: 0,
filter: "blur(4px)",
}}
transition={{
duration: 0.3,
ease: [0.4, 0, 1, 1],
}}
className="overflow-hidden"
>
{row(session)}
</motion.div>
))}
</AnimatePresence>
)}
</div>
);
}