List

Displays item collections with built-in selection and actions.

import { List } from 'ui-lab-components/list';
"use client";
 
import { useState } from 'react';
 
import { Icon } from 'ui-lab-components/icon';
import { List } from 'ui-lab-components/list';
import { Menu } from 'ui-lab-components/menu';
 
const todos = [
  { id: 'todo-1', title: 'Buy groceries', desc: 'Milk, eggs, bread, and fruit' },
  { id: 'todo-2', title: 'Walk the dog', desc: 'Evening walk at the park' },
  { id: 'todo-3', title: 'Read a book', desc: 'Chapter 5 of "Design Systems"' },
];
 
export default function Example() {
  const [selected, setSelected] = useState(() => new Set([todos[1].id]));
  const selectItem = (id: string, isSelected: boolean) => {
    setSelected((current) => {
      const next = new Set(current);
      if (isSelected) next.add(id);
      else next.delete(id);
      return next;
    });
  };
 
  return (
    <List aria-label="Todo list">
      {todos.map((todo) => (
        <List.Item
          key={todo.id}
          value={todo.id}
          interactive
          actions={[
            <Menu key="actions" type="pop-over">
              <Menu.Trigger
                icon={<Icon id="more" size="1em" />}
                size="icon"
                variant="ghost"
                aria-label={`Actions for ${todo.title}`}
              />
              <Menu.Content align="end">
                <Menu.Item onSelect={() => {}}>Edit</Menu.Item>
                <Menu.Item onSelect={() => {}}>Duplicate</Menu.Item>
                <Menu.Item onSelect={() => {}}>Move to...</Menu.Item>
                <Menu.Separator />
                <Menu.Item onSelect={() => {}} styles={{ root: 'text-destructive' }}>
                  Delete
                </Menu.Item>
              </Menu.Content>
            </Menu>,
          ]}
        >
          <List.Checkbox
            aria-label={`Select ${todo.title}`}
            placement="start"
            selected={selected.has(todo.id)}
            onChange={(value) => selectItem(todo.id, value)}
          />
          <div className="min-w-0 flex-1">
            <List.Title>{todo.title}</List.Title>
            <List.Desc>{todo.desc}</List.Desc>
          </div>
        </List.Item>
      ))}
    </List>
  );
}
 

list/01-basic-list

A recent projects panel — placeholder icons and titles ending in a Button to view all.

Loading interactive preview
import { List } from 'ui-lab-components/list'
 
const projects = [
  { id: "design-system", title: "Design system audit" },
  { id: "onboarding", title: "Onboarding flow" },
  { id: "billing", title: "Billing dashboard" },
];
 
export default function BasicList() {
  return (
    <div className="flex flex-col gap-4 rounded-sm">
 
      <List aria-label="Recent projects">
        <List.Header>
          <h2>Projects</h2>
        </List.Header>
        {projects.map((project) => (
          <List.Item key={project.id} value={project.id} interactive>
            <List.Title>{project.title}</List.Title>
          </List.Item>
        ))}
      </List>
    </div>
  );
}
 

list/02-review-queue

A sign-off checklist beneath a muted reviewer header, gated by a Button that unlocks once every row is checked.

Loading interactive preview
"use client";
 
import { useState } from "react";
import { List } from 'ui-lab-components/list';
import { Button } from 'ui-lab-components/button';
import { Icon } from 'ui-lab-components/icon';
 
const reviewItems = [
  { id: "legal", title: "Legal review", desc: "Updated retention language" },
  { id: "security", title: "Security review", desc: "New access scopes" },
  { id: "billing", title: "Billing review", desc: "Invoice copy changes" },
];
 
export default function ReviewQueue() {
  const [checked, setChecked] = useState(() => new Set(["legal"]));
  const allChecked = checked.size === reviewItems.length;
 
  const setItem = (id: string, value: boolean) => {
    setChecked((current) => {
      const next = new Set(current);
      value ? next.add(id) : next.delete(id);
      return next;
    });
  };
 
  const toggleItem = (id: string) => {
    setChecked((current) => {
      const next = new Set(current);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };
 
  return (
    <div className="flex flex-col gap-4 rounded-sm p-4">
 
      <List spacing="sm">
        {reviewItems.map((item) => (
          <List.Item key={item.id} value={item.id} interactive onClick={() => toggleItem(item.id)}>
            <List.Checkbox
              aria-label={`Mark ${item.title} reviewed`}
              placement="start"
              selected={checked.has(item.id)}
              onChange={(value) => setItem(item.id, value)}
            />
            <div className="min-w-0 flex-1">
              <List.Title>{item.title}</List.Title>
              <List.Desc>{item.desc}</List.Desc>
            </div>
          </List.Item>
        ))}
      </List>
 
      <Button size="sm" icon={{ left: <Icon id="check-circle" size="1em" /> }} disabled={!allChecked}>
        Submit review
      </Button>
    </div>
  );
}
 

list/03-notification-rules

Rows can combine a leading checkbox with a trailing Select action.

Loading interactive preview
"use client";
 
import { useState } from "react";
import { List } from 'ui-lab-components/list'
import { Select } from 'ui-lab-components/select';
 
const notificationItems = [
  { id: "comments", title: "Comments", desc: "Replies and mentions" },
  { id: "deployments", title: "Deployments", desc: "Preview and production updates" },
  { id: "incidents", title: "Incidents", desc: "Status changes and postmortems" },
];
 
export default function NotificationRules() {
  const [checked, setChecked] = useState(() => new Set(["comments", "incidents"]));
  const [delivery, setDelivery] = useState<Record<string, string | number | null>>({
    comments: "digest",
    deployments: "email",
    incidents: "push",
  });
 
  const setItem = (id: string, value: boolean) => {
    setChecked((current) => {
      const next = new Set(current);
      value ? next.add(id) : next.delete(id);
      return next;
    });
  };
 
  const toggleItem = (id: string) => {
    setChecked((current) => {
      const next = new Set(current);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };
 
  const setDeliveryMode = (id: string, value: string | number | null) => {
    setDelivery((current) => ({ ...current, [id]: value }));
  };
 
  return (
    <div className="flex flex-col gap-4 rounded-sm p-4">
 
      <List spacing="sm">
        {notificationItems.map((item) => (
          <List.Item key={item.id} value={item.id} interactive onClick={() => toggleItem(item.id)}>
            <List.Checkbox
              aria-label={`Enable ${item.title.toLowerCase()} notifications`}
              placement="start"
              selected={checked.has(item.id)}
              onChange={(value) => setItem(item.id, value)}
            />
            <div className="min-w-0 flex-1">
              <List.Title>{item.title}</List.Title>
              <List.Desc>{item.desc}</List.Desc>
            </div>
            <List.Select
              selectedKey={delivery[item.id]}
              label={String(delivery[item.id] ?? "")}
              disabled={!checked.has(item.id)}
              className={!checked.has(item.id) ? "pointer-events-none" : undefined}
              onSelectionChange={(value) => setDeliveryMode(item.id, value)}
            >
              <Select.Trigger>
                <Select.Value placeholder="Mode" />
              </Select.Trigger>
              <Select.Content>
                <Select.List>
                  <Select.Item value="email">Email</Select.Item>
                  <Select.Item value="digest">Digest</Select.Item>
                  <Select.Item value="push">Push</Select.Item>
                </Select.List>
              </Select.Content>
            </List.Select>
          </List.Item>
        ))}
      </List>
    </div>
  );
}
 

list/04-quota-editor

Inline inputs work as row actions without taking over the List primitive.

Loading interactive preview
"use client";
 
import { useState } from "react";
import { List } from 'ui-lab-components/list';
 
const quotaItems = [
  { id: "seats", title: "Seats", desc: "Maximum workspace members" },
  { id: "projects", title: "Projects", desc: "Active projects per workspace" },
  { id: "tokens", title: "Tokens", desc: "Monthly API token budget" },
];
 
export default function QuotaEditor() {
  const [checked, setChecked] = useState(() => new Set(["seats", "projects"]));
  const [limits, setLimits] = useState<Record<string, string>>({
    seats: "24",
    projects: "12",
    tokens: "50000",
  });
 
  const setItem = (id: string, value: boolean) => {
    setChecked((current) => {
      const next = new Set(current);
      value ? next.add(id) : next.delete(id);
      return next;
    });
  };
 
  const toggleItem = (id: string) => {
    setChecked((current) => {
      const next = new Set(current);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };
 
  const setLimit = (id: string, value: string) => {
    setLimits((current) => ({ ...current, [id]: value }));
  };
 
  return (
    <div className="flex flex-col gap-4 rounded-sm p-4">
 
      <List spacing="sm">
        {quotaItems.map((item) => (
          <List.Item key={item.id} value={item.id} interactive onClick={() => toggleItem(item.id)}>
            <List.Checkbox
              aria-label={`Enable ${item.title.toLowerCase()} limit`}
              placement="start"
              selected={checked.has(item.id)}
              onChange={(value) => setItem(item.id, value)}
            />
            <div className="flex min-w-0 flex-1 flex-col gap-1">
              <List.Title>{item.title}</List.Title>
              <List.Desc>{item.desc}</List.Desc>
            </div>
            <List.Input
              aria-label={`${item.title} limit`}
              type="number"
              value={limits[item.id]}
              disabled={!checked.has(item.id)}
              onChange={(event) => setLimit(item.id, event.currentTarget.value)}
              className="w-28"
            />
          </List.Item>
        ))}
      </List>
    </div>
  );
}
 

list/05-permissions-matrix

A parent checkbox can summarize rows that also expose per-row Select controls.

Loading interactive preview
"use client";
 
import { useMemo, useState } from "react";
import { List } from 'ui-lab-components/list'
import { Select } from 'ui-lab-components/select';
 
const permissionItems = [
  { id: "members", title: "Members", desc: "Invite and remove workspace members" },
  { id: "billing", title: "Billing", desc: "Update plan, seats, and invoices" },
  { id: "tokens", title: "Tokens", desc: "Issue scoped API credentials" },
];
 
export default function PermissionsMatrix() {
  const rows = useMemo(() => [{ id: "all", title: "All permissions" }, ...permissionItems], []);
  const [checked, setChecked] = useState(() => new Set(["members", "tokens"]));
  const [level, setLevel] = useState<Record<string, string | number | null>>({
    members: "edit",
    billing: "view",
    tokens: "edit",
  });
  const allChecked = checked.size === permissionItems.length;
  const isIndeterminate = checked.size > 0 && !allChecked;
 
  const setItem = (id: string, value: boolean) => {
    setChecked((current) => {
      const next = new Set(current);
      value ? next.add(id) : next.delete(id);
      return next;
    });
  };
 
  const setAll = (value: boolean) => {
    permissionItems.forEach((item) => setItem(item.id, value));
  };
 
  const setPermissionLevel = (id: string, value: string | number | null) => {
    setLevel((current) => ({ ...current, [id]: value }));
  };
 
  return (
    <div className="flex flex-col gap-4 rounded-sm p-4">
 
      <List spacing="sm">
        <List.Item value="all" interactive onClick={() => setAll(!allChecked)}>
          <List.Checkbox
            aria-label="Toggle all permissions"
            placement="start"
            selected={allChecked}
            indeterminate={isIndeterminate}
            onChange={setAll}
          />
          <List.Title>All permissions</List.Title>
        </List.Item>
        <List.Divider />
        {permissionItems.map((item) => (
          <List.Item key={item.id} value={item.id} interactive onClick={() => setItem(item.id, !checked.has(item.id))}>
            <div className="w-5 flex-shrink-0" />
            <List.Checkbox
              aria-label={`Allow ${item.title.toLowerCase()}`}
              placement="start"
              selected={checked.has(item.id)}
              onChange={(value) => setItem(item.id, value)}
            />
            <div className="min-w-0 flex-1">
              <List.Title>{item.title}</List.Title>
              <List.Desc>{item.desc}</List.Desc>
            </div>
            <List.Select
              selectedKey={level[item.id]}
              label={String(level[item.id] ?? "")}
              disabled={!checked.has(item.id)}
              className={!checked.has(item.id) ? "pointer-events-none" : undefined}
              onSelectionChange={(value) => setPermissionLevel(item.id, value)}
            >
              <Select.Trigger>
                <Select.Value placeholder="Access" />
              </Select.Trigger>
              <Select.Content>
                <Select.List>
                  <Select.Item value="view">View</Select.Item>
                  <Select.Item value="edit">Edit</Select.Item>
                  <Select.Item value="admin">Admin</Select.Item>
                </Select.List>
              </Select.Content>
            </List.Select>
          </List.Item>
        ))}
      </List>
    </div>
  );
}
 

list/06-expandable-tree

Nested folders flattened into visible rows, toggled by click, Enter, or the arrow keys while List keeps owning focus.

Loading interactive preview
"use client";
 
import { useState, type ReactNode } from "react";
import { List } from 'ui-lab-components/list';
import { Expand } from 'ui-lab-components/expand';
import { Icon } from 'ui-lab-components/icon';
import { Menu } from 'ui-lab-components/menu';
 
type Node = { id: string; title: string; icon: string; count?: number; children?: Node[] };
 
const tree: Node[] = [
  {
    id: "gallery", title: "Media library", icon: "image", count: 7,
    children: [
      {
        id: "brand-assets", title: "Brand assets", icon: "folder", count: 3,
        children: [
          { id: "primary-logo", title: "Primary logo.svg", icon: "image" },
          { id: "team-photo", title: "Team photo.jpg", icon: "image" },
          { id: "brand-cover", title: "Brand cover.png", icon: "image" },
        ],
      },
      {
        id: "product-images", title: "Product images", icon: "folder", count: 2,
        children: [
          { id: "dashboard-overview", title: "Dashboard overview.png", icon: "image" },
          { id: "workspace-preview", title: "Workspace preview.png", icon: "image" },
        ],
      },
      {
        id: "campaigns", title: "Campaigns", icon: "folder", count: 2,
        children: [
          { id: "launch-banner", title: "Launch banner.jpg", icon: "image" },
          { id: "newsletter-header", title: "Newsletter header.png", icon: "image" },
        ],
      },
    ],
  },
];
 
export default function ExpandableTree() {
  const [open, setOpen] = useState(() => new Set(["gallery", "brand-assets"]));
 
  const setExpanded = (id: string, value: boolean) => {
    setOpen((current) => {
      const next = new Set(current);
      value ? next.add(id) : next.delete(id);
      return next;
    });
  };
 
  const renderRows = (nodes: Node[], depth: number): ReactNode[] =>
    nodes.map((node) => {
      const isBranch = Boolean(node.children);
      const expanded = open.has(node.id);
 
      const row = (
        <List.Item
          key={node.id}
          value={node.id}
          interactive
          depth={depth}
          expanded={isBranch ? expanded : undefined}
          onExpandedChange={(value) => setExpanded(node.id, value)}
          actions={[
            <Menu key="actions" type="pop-over">
              <Menu.Trigger
                icon={<Icon id="more" variant="duo-tone" size="1em" />}
                variant="ghost"
                aria-label={`More actions for ${node.title}`}
              />
              <Menu.Content align="end">
                <Menu.Item onSelect={() => {}}>Open</Menu.Item>
                <Menu.Item onSelect={() => {}}>Rename</Menu.Item>
                <Menu.Separator />
                <Menu.Item onSelect={() => {}} styles={{ root: "text-destructive" }}>
                  Delete
                </Menu.Item>
              </Menu.Content>
            </Menu>,
          ]}
        >
          <List.Media>
            <Icon id={node.icon} variant="duo-tone" />
          </List.Media>
          <List.Title className="truncate">{node.title}</List.Title>
          {node.count !== undefined && <List.Meta>{node.count}</List.Meta>}
        </List.Item>
      );
 
      if (!isBranch) return row;
 
      return (
        <Expand
          key={node.id}
          state={{ expanded }}
          styles={{ content: { inner: "flex flex-col gap-0.5 pt-0.5" } }}
        >
          {row}
          <Expand.Content inert={!expanded}>{renderRows(node.children!, depth + 1)}</Expand.Content>
        </Expand>
      );
    });
 
  return (
    <List
      aria-label="Media library folders"
      className="w-96"
      styles={{ item: { actions: "invisible group-hover/item:visible" } }}
    >
      {renderRows(tree, 0)}
    </List>
  );
}