Session Device List
Data Display

Session Device List

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.

Install

npx shadcn@latest add @paragon/session-device-list

session-device-list.tsx

"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>
  );
}