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