# Resource List

Display resources with status indicators, metadata and optional interactive rows.

Project list with status indicators

[Complete TSX file](/examples/resource-list/default.tsx)

```tsx
"use client";

import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const projects = [
  {
    name: "Website redesign",
    status: "Active",
    team: "Design team",
    tone: "success",
  },
  {
    name: "Mobile app",
    status: "At risk",
    team: "Product team",
    tone: "warning",
  },
  {
    name: "Data migration",
    status: "Paused",
    team: "Platform team",
    tone: "neutral",
  },
] as const;

export default function Example() {
  return (
    <ResourceList className="w-full max-w-md">
      {projects.map((project) => (
        <ResourceListItem key={project.name}>
          <ResourceListIndicator tone={project.tone} />
          <ResourceListContent>
            <ResourceListTitle>{project.name}</ResourceListTitle>
            <ResourceListDescription>{project.team}</ResourceListDescription>
          </ResourceListContent>
          <ResourceListMeta>{project.status}</ResourceListMeta>
        </ResourceListItem>
      ))}
    </ResourceList>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/resource-list
```

With npm: `npx shadcn@4.18.0 add @siteplane/resource-list`. [Registry JSON](/registry/resource-list.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/card
```

## Usage

```tsx
import {
  ResourceList,
  ResourceListItem,
  ResourceListIndicator,
  ResourceListContent,
  ResourceListTitle,
  ResourceListDescription,
  ResourceListMeta,
} from "@/siteplane/components/resource-list";

<ResourceList>
  <ResourceListItem>
    <ResourceListIndicator tone="success" />
    <ResourceListContent>
      <ResourceListTitle>Website redesign</ResourceListTitle>
      <ResourceListDescription>Design team</ResourceListDescription>
    </ResourceListContent>
    <ResourceListMeta>Active</ResourceListMeta>
  </ResourceListItem>
</ResourceList>;
```

## Composition

```text
ResourceList
├── ResourceListHeader
└── ResourceListItem
    ├── ResourceListIndicator
    ├── ResourceListContent
    │   ├── ResourceListTitle
    │   └── ResourceListDescription
    └── ResourceListMeta
```

The header, the indicator and the meta are optional, and a row without an indicator or a meta takes no room for them. In a row with a description, the indicator and text in the meta, such as a date, sit on the line of the title. A control in the meta, such as a button or a chevron, stays centered on the row.

A title and a description stay on one line and end with an ellipsis when they are too long, so a dense list keeps its rhythm. Both carry a `title` attribute with their full text, which the browser shows on hover; pass a `title` of your own to replace it. Text children give the attribute in the server-rendered HTML. When an element is among the children, such as a quieter suffix, the attribute is set from the rendered text once the list has mounted, so a list rendered by a Server Component hydrates without a mismatch.

## Navigation

Set `interactive` and render an anchor to turn each row into a link. `active` marks the current page with `aria-current="page"`.

Linked projects with the current item

[Complete TSX file](/examples/resource-list/navigation.tsx)

```tsx
"use client";

import { ChevronRightIcon } from "lucide-react";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const currentHref = "#website-redesign";

const projects = [
  {
    href: "#website-redesign",
    name: "Website redesign",
    team: "Design team",
  },
  { href: "#mobile-app", name: "Mobile app", team: "Product team" },
  {
    href: "#data-migration",
    name: "Data migration",
    team: "Platform team",
  },
];

export default function Example() {
  return (
    <ResourceList className="w-full max-w-md">
      {projects.map((project) => (
        <ResourceListItem
          active={project.href === currentHref}
          interactive
          key={project.href}
          render={<a href={project.href} />}
        >
          <ResourceListContent>
            <ResourceListTitle>{project.name}</ResourceListTitle>
            <ResourceListDescription>{project.team}</ResourceListDescription>
          </ResourceListContent>
          <ResourceListMeta>
            <ChevronRightIcon className="size-4" />
          </ResourceListMeta>
        </ResourceListItem>
      ))}
    </ResourceList>
  );
}
```

## Icon indicators

Give an indicator an `icon` and a `label` so the status isn't conveyed by color alone. Without a label, the indicator is hidden from assistive technologies.

Tasks with icon indicators

[Complete TSX file](/examples/resource-list/indicators.tsx)

```tsx
"use client";

import { CalendarIcon, CheckIcon, ClockIcon } from "lucide-react";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const tasks = [
  {
    due: "Sep 18",
    icon: <CheckIcon />,
    project: "Website redesign",
    status: "Done",
    title: "Review the project brief",
    tone: "success",
  },
  {
    due: "Sep 24",
    icon: <ClockIcon />,
    project: "Mobile app",
    status: "Due soon",
    title: "Prepare the design handoff",
    tone: "warning",
  },
  {
    due: "Oct 2",
    icon: <CalendarIcon />,
    project: "Data migration",
    status: "Scheduled",
    title: "Plan the next sprint",
    tone: "info",
  },
] as const;

export default function Example() {
  return (
    <ResourceList className="w-full max-w-md">
      {tasks.map((task) => (
        <ResourceListItem key={task.title}>
          <ResourceListIndicator
            icon={task.icon}
            label={task.status}
            tone={task.tone}
          />
          <ResourceListContent>
            <ResourceListTitle>{task.title}</ResourceListTitle>
            <ResourceListDescription>{task.project}</ResourceListDescription>
          </ResourceListContent>
          <ResourceListMeta>{task.due}</ResourceListMeta>
        </ResourceListItem>
      ))}
    </ResourceList>
  );
}
```

## Density and surface

Set `density="compact"` for long lists and `variant="soft"` to group the rows on a subtle surface. Without `density`, a list takes the density of its `SiteplaneUIProvider`. Only the list's own density decides: a compact container around a list, such as a compact Card, leaves it as it is.

Compact list on a soft surface

[Complete TSX file](/examples/resource-list/dense-list.tsx)

```tsx
"use client";

import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const environments = [
  { branch: "main", name: "Production", status: "Healthy", tone: "success" },
  { branch: "release", name: "Staging", status: "Deploying", tone: "info" },
  { branch: "checkout", name: "Preview", status: "Failed", tone: "error" },
  { branch: "develop", name: "Development", status: "Idle", tone: "neutral" },
] as const;

export default function Example() {
  return (
    <ResourceList className="w-full max-w-md" density="compact" variant="soft">
      {environments.map((environment) => (
        <ResourceListItem key={environment.name}>
          <ResourceListIndicator tone={environment.tone} />
          <ResourceListContent>
            <ResourceListTitle>{environment.name}</ResourceListTitle>
            <ResourceListDescription>
              Branch {environment.branch}
            </ResourceListDescription>
          </ResourceListContent>
          <ResourceListMeta>{environment.status}</ResourceListMeta>
        </ResourceListItem>
      ))}
    </ResourceList>
  );
}
```

## Aligning with the content around the list

A row's content sits 12 px inside the list, because the row has its own padding and its hover and selected fills surround the content. Set `flush` on `ResourceList` to line the content up with the content around the list instead, such as a Filter Bar or a Card heading. The fills keep their inset look and reach past the edge of the list by the row padding, so put the list in a container with at least that much room, as a Card panel has. On the `soft` surface the reach is 16 px, because the surface's own padding adds to it. The list is as wide as its container plus that reach, so set a width on a wrapper instead of on the list.

## Divided rows

Set `variant="divided"` to separate the rows with the hairline that cards and separators draw. The line runs across the row content, from where the text starts to where it ends, in the middle of the space between two rows, so a current or selected row's fill stays clear of it. The first row keeps no space above its content and the last row none below it: the list reaches past its top and bottom by the row padding, as `flush` makes it reach past its sides, so the text lines up with the content around the list. Combine it with `flush` in a Card panel, as here. A row that is added or removed brings or takes its line along.

Recent changes in a card, divided by hairlines

[Complete TSX file](/examples/resource-list/divided.tsx)

```tsx
"use client";

import {
  Card,
  CardHeader,
  CardPanel,
  CardTitle,
} from "@/siteplane/components/card";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const changes = [
  {
    field: "Opening hours",
    id: "hours",
    page: "Contact page",
    time: "2 h ago",
  },
  { field: "Prices", id: "prices", page: "Services page", time: "Yesterday" },
  { field: "Team photos", id: "team", page: "About page", time: "Oct 12" },
  { field: "Lunch menu", id: "menu", page: "Home page", time: "Oct 10" },
];

export default function Example() {
  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Recent changes</CardTitle>
      </CardHeader>
      <CardPanel>
        <ResourceList flush variant="divided">
          {changes.map((change) => (
            <ResourceListItem key={change.id}>
              <ResourceListContent>
                <ResourceListTitle>{change.field}</ResourceListTitle>
                <ResourceListDescription>{change.page}</ResourceListDescription>
              </ResourceListContent>
              <ResourceListMeta>{change.time}</ResourceListMeta>
            </ResourceListItem>
          ))}
        </ResourceList>
      </CardPanel>
    </Card>
  );
}
```

### Clickable rows and a header

When the rows are clickable (`interactive`), their hover surface takes the whole row, so the lines run as wide as that surface instead of ending at the text, with `flush` and without it. Put a `ResourceListHeader` first in the list to name it. Its text starts where the rows' text does, in the muted color of their metadata, and a second child, such as an action, sits at its end. In a divided list the header gets the same line below it as the rows, and the first row sits as far below that line as every row sits below its own. Without a header, the list keeps the geometry described above. Render the header as a heading with `render`, such as `render={<h3 />}`, when it belongs to the page's outline.

Recently published, with a header and clickable rows

[Complete TSX file](/examples/resource-list/divided-header.tsx)

```tsx
"use client";

import { Card, CardPanel } from "@/siteplane/components/card";
import {
  ResourceList,
  ResourceListContent,
  ResourceListHeader,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const publishes = [
  { by: "Anna Gruber", id: "anna", page: "Home", time: "2 h ago" },
  { by: "Lea Hartmann", id: "lea", page: "Contact", time: "Yesterday" },
  { by: "Jonas Weber", id: "jonas", page: "Services", time: "Oct 12" },
];

export default function Example() {
  return (
    <Card className="w-full max-w-md">
      <CardPanel>
        <ResourceList flush variant="divided">
          <ResourceListHeader>Recently published</ResourceListHeader>
          {publishes.map((publish) => (
            <ResourceListItem
              interactive
              key={publish.id}
              render={<a href={`#${publish.id}`} />}
            >
              <ResourceListContent>
                <ResourceListTitle>
                  {publish.by}{" "}
                  <span className="text-muted-foreground">
                    · {publish.page}
                  </span>
                </ResourceListTitle>
              </ResourceListContent>
              <ResourceListMeta>{publish.time}</ResourceListMeta>
            </ResourceListItem>
          ))}
        </ResourceList>
      </CardPanel>
    </Card>
  );
}
```

## Adding and removing rows

Rows that are added, removed or reordered animate on their own: a row expands and fades in, a leaving row fades out and collapses, and the rows around it glide into their new place. Give every row a stable `key`, such as an id and never the index, and render the rows as direct children of `ResourceList`. When a row that holds focus leaves, for example because its Remove button was pressed, focus moves to the next row, or to the previous one at the end. The same goes for focus that a popup opened from the row would return to it.

Projects with a Remove action

[Complete TSX file](/examples/resource-list/removable.tsx)

```tsx
"use client";

import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const allProjects = [
  {
    id: "website-redesign",
    name: "Website redesign",
    team: "Design team",
    tone: "success",
  },
  { id: "mobile-app", name: "Mobile app", team: "Product team", tone: "info" },
  {
    id: "data-migration",
    name: "Data migration",
    team: "Platform team",
    tone: "warning",
  },
  {
    id: "billing-cleanup",
    name: "Billing cleanup",
    team: "Finance team",
    tone: "neutral",
  },
] as const;

export default function Example() {
  const [projects, setProjects] =
    useState<readonly (typeof allProjects)[number][]>(allProjects);

  return (
    <div className="grid w-full max-w-md gap-3">
      <ResourceList>
        {projects.map((project) => (
          <ResourceListItem key={project.id}>
            <ResourceListIndicator tone={project.tone} />
            <ResourceListContent>
              <ResourceListTitle>{project.name}</ResourceListTitle>
              <ResourceListDescription>{project.team}</ResourceListDescription>
            </ResourceListContent>
            <ResourceListMeta>
              <Button
                aria-label={`Remove ${project.name}`}
                onClick={() =>
                  setProjects((list) =>
                    list.filter((item) => item.id !== project.id),
                  )
                }
                shape="square"
                variant="ghost"
              >
                <Trash2Icon />
              </Button>
            </ResourceListMeta>
          </ResourceListItem>
        ))}
      </ResourceList>
      <Button
        disabled={projects.length === allProjects.length}
        onClick={() => setProjects(allProjects)}
        variant="outline"
      >
        Restore projects
      </Button>
    </div>
  );
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| ResourceList.density | ResourceListDensity: default \| compact | provider / default | Row spacing. |
| ResourceList.flush | boolean | false | Aligns the row content with the edges of the list. The rows reach past the edges by their padding, so the hover and selected fills stay inset. |
| ResourceList.variant | ResourceListVariant: default \| soft \| divided | default | soft groups the rows on a subtle surface; divided draws a hairline between two rows and keeps no space outside the first and the last row's content. With interactive rows the hairline is as wide as their hover surface. |
| ResourceListItem.active | boolean | false | Current item: aria-current="page" on a linked row, aria-current="true" on other rows. |
| ResourceListItem.selected | boolean | unset | Selected appearance. On an interactive button row it also sets aria-pressed. |
| ResourceListItem.interactive | boolean | false | Render the row using a real Button. |
| ResourceListItem.disabled | boolean | false | Unavailable presentation; Button guards activation for interactive rows. |
| ResourceListIndicator.shape | bar \| diamond \| dot \| ring | dot | Marker shape. |
| ResourceListIndicator.tone | chart-1 … chart-5 \| error \| info \| neutral \| success \| warning | neutral | Marker color. muted still works as a deprecated alias of neutral. |
| ResourceListIndicator.icon | ReactNode | unset | Optional icon content. |
| ResourceListIndicator.label | string | unset | Accessible marker label. |
| ResourceListMeta.selected | boolean | unset | Shows the selection check alongside metadata. The check draws in when the value becomes true and out when it becomes false, and appears instantly under reduced motion. |
ResourceListProps, ResourceListItemProps and ResourceListIndicatorProps export
these contracts, together with density/variant/indicator tone/shape types.
List/Item/Content accept useRender div props; Indicator/Title/Description/Meta
accept useRender span props. All accept render, string className and refs. A
non-interactive row is a div; active/selected do not create listbox selection, a
form value or keyboard navigation. No row data model or loading/empty props are
provided. Rows animate through the `motion` component, which installs with
Resource List; reduced motion makes every change instant.

`ResourceListHeader` accepts useRender div props, as the list does, and has no props of its own.

`ResourceListIndicatorTone` and `ResourceListIndicatorShape` name the tone and shape unions listed above; `ResourceListIndicatorDeprecatedTone` holds the earlier `muted`.

[Read the installed source](/source/resource-list).

## Usage notes

Use ListBox for managed keyboard selection. On an interactive button row, `selected` sets `aria-pressed`; pass `selected={false}` to the other selectable rows so each one reports its state. On links and static rows, and on `ResourceListMeta`, `selected` changes only the appearance. For a resource collection, the application owns fetching, ordering, empty/error messages and selected-record state. Avoid nested buttons inside an interactive row: put an action button in a static row, as in the Remove example. A row inside a wrapper component, and anything else the list contains, appears and leaves at once.
