Siteplane UI

Resource List

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

Markdown
Website redesignDesign team
Active
Mobile appProduct team
At risk
Data migrationPlatform team
Paused
"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/card

Usage

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

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.

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

"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.

Review the project briefWebsite redesign
Sep 18
Prepare the design handoffMobile app
Sep 24
Plan the next sprintData migration
Oct 2
"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.

ProductionBranch main
Healthy
StagingBranch release
Deploying
PreviewBranch checkout
Failed
DevelopmentBranch develop
Idle
"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
Opening hoursContact page
2 h ago
PricesServices page
Yesterday
Team photosAbout page
Oct 12
Lunch menuHome page
Oct 10
"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.

"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.

Website redesignDesign team
Mobile appProduct team
Data migrationPlatform team
Billing cleanupFinance team
"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

Component properties
PropTypeDefaultDescription
ResourceList.densityResourceListDensity: default | compactprovider / defaultRow spacing.
ResourceList.flushbooleanfalseAligns 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.variantResourceListVariant: default | soft | divideddefaultsoft 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.activebooleanfalseCurrent item: aria-current="page" on a linked row, aria-current="true" on other rows.
ResourceListItem.selectedbooleanunsetSelected appearance. On an interactive button row it also sets aria-pressed.
ResourceListItem.interactivebooleanfalseRender the row using a real Button.
ResourceListItem.disabledbooleanfalseUnavailable presentation; Button guards activation for interactive rows.
ResourceListIndicator.shapebar | diamond | dot | ringdotMarker shape.
ResourceListIndicator.tonechart-1 … chart-5 | error | info | neutral | success | warningneutralMarker color. muted still works as a deprecated alias of neutral.
ResourceListIndicator.iconReactNodeunsetOptional icon content.
ResourceListIndicator.labelstringunsetAccessible marker label.
ResourceListMeta.selectedbooleanunsetShows 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.

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.