Propel

List

A vertical roving-focus list of navigation and action rows, the primitive sidebars compose.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemButton, ListItemLink } from "@makeplane/propel/components/list";
import { Ellipsis, Inbox, LayoutGrid, Settings } from "lucide-react";

export default function BasicDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Workspace">
        <ListItem>
          <ListItemLink href="#inbox" startIcon={<Icon icon={Inbox} size="md" />} label="Inbox" />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#projects"
            aria-current="page"
            startIcon={<Icon icon={LayoutGrid} size="md" />}
            label="Projects"
          />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#settings"
            startIcon={<Icon icon={Settings} size="md" />}
            label="Settings"
          />
        </ListItem>
        <ListItem>
          <ListItemButton startIcon={<Icon icon={Ellipsis} size="md" />} label="More" />
        </ListItem>
      </List>
    </div>
  );
}

Installation

import {
  List,
  ListItem,
  ListItemButton,
  ListItemCounter,
  ListItemDisclosureTrigger,
  ListItemLink,
  ListSection,
  ListSectionHeading,
} from "@makeplane/propel/components/list";

Usage

import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemButton, ListItemLink } from "@makeplane/propel/components/list";
import { Ellipsis, Inbox, LayoutGrid, Settings } from "lucide-react";

export default function BasicDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Workspace">
        <ListItem>
          <ListItemLink href="#inbox" startIcon={<Icon icon={Inbox} size="md" />} label="Inbox" />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#projects"
            aria-current="page"
            startIcon={<Icon icon={LayoutGrid} size="md" />}
            label="Projects"
          />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#settings"
            startIcon={<Icon icon={Settings} size="md" />}
            label="Settings"
          />
        </ListItem>
        <ListItem>
          <ListItemButton startIcon={<Icon icon={Ellipsis} size="md" />} label="More" />
        </ListItem>
      </List>
    </div>
  );
}

Examples

Nesting

level (1-5) indents a row to show hierarchy — a workspace containing projects containing pages.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink } from "@makeplane/propel/components/list";
import { Inbox, LayoutGrid, Settings } from "lucide-react";

export default function NestingDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Workspace">
        <ListItem level={1}>
          <ListItemLink
            href="#workspace"
            startIcon={<Icon icon={LayoutGrid} size="md" />}
            label="Workspace"
          />
        </ListItem>
        <ListItem level={2}>
          <ListItemLink
            href="#project"
            startIcon={<Icon icon={Inbox} size="md" />}
            label="Project"
          />
        </ListItem>
        <ListItem level={3}>
          <ListItemLink
            href="#page"
            aria-current="page"
            startIcon={<Icon icon={Settings} size="md" />}
            label="Page"
          />
        </ListItem>
      </List>
    </div>
  );
}

Text density

density sets the row’s label/counter text scale: comfortable (the default, 14px) or compact (13px). Row height and icon size stay fixed either way — this is not a ladder size (those labels map to control heights).

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink } from "@makeplane/propel/components/list";
import { Inbox } from "lucide-react";

export default function DensityDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Text density">
        <ListItem density="comfortable">
          <ListItemLink
            href="#comfortable"
            startIcon={<Icon icon={Inbox} size="md" />}
            label="comfortable — 14px"
            count={6}
          />
        </ListItem>
        <ListItem density="compact">
          <ListItemLink
            href="#compact"
            startIcon={<Icon icon={Inbox} size="md" />}
            label="compact — 13px"
            count={6}
          />
        </ListItem>
      </List>
    </div>
  );
}

Row disclosure

ListItemDisclosureTrigger is a row’s own expand/collapse control — a sibling of the row’s primary, never nested inside it, since a button can’t nest inside a link or button. It is a roving CompositeItem in the parent List (one tab stop with the primary; arrow keys move between them).

Unlike ListSection (which wraps Base UI Collapsible for you), there is no ready-made row collapsible. Compose it yourself with Collapsible.Root from @base-ui/react/collapsible:

  1. Give each expandable row its own Collapsible.Root.
  2. Inside the root: a parent List that holds only that expandable row (primary + disclosure trigger).
  3. Put Collapsible.Panel next to that parent List, still inside the same root — same shape ListSection uses, scoped to one row. Wrap the nested child List in ListSectionPanelContent (heading-to-row gap only). Do not use CollapsiblePanelContent here — its inset and body-xs prose are for Collapsible’s own panel body; list rows already own their padding.
  4. Sibling top-level rows that are not children of the expandable row go in a separate List outside that root (after it in the stack). Do not put those siblings in the same parent List as the expandable row: the panel is a sibling of the list, so it would render after every list item and break visual hierarchy.

This example shows count and endIcon on the expandable row, nested children under it, then a sibling Settings row after the cluster.

Trailing order on a row: countendIcon → disclosure trigger. count and endIcon live inside the primary (ListItemLink / ListItemButton) — they are visual only; clicks still hit the link/button. Interactive trailing controls (disclosure, a “more” menu, etc.) must be siblings of the primary, never passed as endIcon. The row’s full focus ring only follows the primary; sibling controls keep their own focus chrome.

Show code
import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
import { CollapsiblePanel } from "@makeplane/propel/components/collapsible";
import { Icon } from "@makeplane/propel/components/icon";
import {
  List,
  ListItem,
  ListItemDisclosureTrigger,
  ListItemLink,
  ListSectionPanelContent,
} from "@makeplane/propel/components/list";
import { LayoutGrid, Settings } from "lucide-react";

/**
 * Each expandable row is its own `Collapsible.Root` cluster (parent `List` + panel). Sibling rows
 * that are not children of that cluster sit in a **separate** `List` after the root — never inside
 * the same parent `List` as the expandable row, or the panel would render after those siblings.
 */
export default function RowDisclosureDemo() {
  return (
    <div className="flex w-64 flex-col gap-0.5">
      <BaseCollapsible.Root defaultOpen>
        <List role="toolbar" aria-label="Projects group">
          <ListItem>
            <ListItemLink
              href="#projects"
              startIcon={<Icon icon={LayoutGrid} size="md" />}
              label="Projects"
              count={3}
              endIcon={<Icon icon={Settings} size="md" />}
            />
            <ListItemDisclosureTrigger aria-label="Toggle Projects" />
          </ListItem>
        </List>
        <BaseCollapsible.Panel render={<CollapsiblePanel />}>
          <ListSectionPanelContent>
            <List role="toolbar" aria-label="Projects">
              <ListItem level={2}>
                <ListItemLink
                  href="#roadmap"
                  startIcon={<Icon icon={Settings} size="md" />}
                  label="Roadmap"
                />
              </ListItem>
            </List>
          </ListSectionPanelContent>
        </BaseCollapsible.Panel>
      </BaseCollapsible.Root>
      <List role="toolbar" aria-label="Workspace">
        <ListItem>
          <ListItemLink
            href="#settings"
            startIcon={<Icon icon={Settings} size="md" />}
            label="Settings"
          />
        </ListItem>
      </List>
    </div>
  );
}

A bare count (e.g. 6) only tells a screen reader “Inbox, 6” — no indication of what the number means, since the component itself can’t know (unread messages? total items? something else?). If that distinction matters for your use case, add it yourself with visually-hidden text — count accepts any node, not just a string or number:

<ListItemLink
  href="#inbox"
  label="Inbox"
  count={
    <>
      <span className="sr-only">unread: </span>6
    </>
  }
/>

Section

Wrap a List in ListSection to get a muted heading that collapses its body. The disclosure chevron rotates as the section opens and closes.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink, ListSection } from "@makeplane/propel/components/list";
import { LayoutGrid, Settings } from "lucide-react";

export default function SectionDemo() {
  return (
    <div className="w-64">
      <ListSection label="Workspace" indicator defaultOpen>
        <List role="toolbar" aria-label="Workspace">
          <ListItem>
            <ListItemLink
              href="#projects"
              aria-current="page"
              startIcon={<Icon icon={LayoutGrid} size="md" />}
              label="Projects"
            />
          </ListItem>
          <ListItem>
            <ListItemLink
              href="#settings"
              startIcon={<Icon icon={Settings} size="md" />}
              label="Settings"
            />
          </ListItem>
        </List>
      </ListSection>
    </div>
  );
}

Section heading

For settings-style sidebars whose groups never collapse, ListSectionHeading titles a List with a static, non-interactive label.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import {
  List,
  ListItem,
  ListItemLink,
  ListSectionHeading,
} from "@makeplane/propel/components/list";
import { Bell, User, Users } from "lucide-react";

export default function SectionHeadingDemo() {
  return (
    <div className="w-64">
      <ListSectionHeading>Members</ListSectionHeading>
      <List role="toolbar" aria-label="Members">
        <ListItem>
          <ListItemLink
            href="#profile"
            aria-current="page"
            startIcon={<Icon icon={User} size="md" />}
            label="Profile"
          />
        </ListItem>
        <ListItem>
          <ListItemLink href="#teams" startIcon={<Icon icon={Users} size="md" />} label="Teams" />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#notifications"
            startIcon={<Icon icon={Bell} size="md" />}
            label="Notifications"
          />
        </ListItem>
      </List>
    </div>
  );
}

Controlled

Drive a section’s open state yourself with open and onOpenChange.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink, ListSection } from "@makeplane/propel/components/list";
import { LayoutGrid, Settings } from "lucide-react";
import * as React from "react";

export default function ControlledDemo() {
  const [open, setOpen] = React.useState(true);
  return (
    <div className="w-64">
      <ListSection label="Workspace" indicator open={open} onOpenChange={(next) => setOpen(next)}>
        <List role="toolbar" aria-label="Workspace">
          <ListItem>
            <ListItemLink
              href="#projects"
              aria-current="page"
              startIcon={<Icon icon={LayoutGrid} size="md" />}
              label="Projects"
            />
          </ListItem>
          <ListItem>
            <ListItemLink
              href="#settings"
              startIcon={<Icon icon={Settings} size="md" />}
              label="Settings"
            />
          </ListItem>
        </List>
      </ListSection>
    </div>
  );
}

API Reference

List itself forwards Base UI’s Composite props and the underlying list element’s HTML attributes — one tab stop for the whole list, arrow keys move between rows. Pass the role/aria-* your context calls for.

ListItem

A row wrapper. Holds a primary `ListItemLink` (or `ListItemButton`) plus optional actions or a count as siblings, and carries the row chrome. `level` indents nested rows; `density` sets the label/counter text scale. Renders a `<div>` by default.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.
level1 | 2 | 3 | 4 | 51Nesting depth — steps the row's left inset by 8px per level (1–5).
density"comfortable" | "compact""comfortable"Label/counter text density. Row height and icon size stay fixed at both values (`comfortable` = 14px, `compact` = 13px). Not a ladder `size` — height is always 32px.

ListItemLink

A list row's primary navigation target — an `<a>` that is also a roving `Composite` item. Base UI's `CompositeItem` roving behavior grafted onto the styled `elements` `ListItemLink` element (rule 1a). Mark the current page with `aria-current="page"`. Pass `startIcon` for the leading visual, `label` for the visible row text, `count` for a trailing count chip, and `endIcon` for a trailing visual. A row's expand/collapse control (`ListItemDisclosureTrigger`) is a separate sibling, never a child — a button can't nest inside this `<a>`.

PropTypeDefaultDescription
labelstringVisible row label.
startIconReactNodeElement rendered before the label, usually `<Icon icon={...} />`.
countReactNodeTrailing count chip, e.g. an unread total. `0` and `""` are omitted (no empty/zero chip); pass a non-empty node (or the string `"0"`) when a zero must stay visible.
endIconReactNodeTrailing visual after the count, usually `<Icon icon={...} />`. Part of the primary hit target — not a separate control. Put interactive trailing actions (disclosure, menus) as siblings of this link, not here.

ListItemButton

A list row's primary action — a `<button>` that is also a roving `Composite` item. Base UI's `CompositeItem` roving behavior grafted onto the styled `elements` `ListItemButton` element (rule 1a). Pass `startIcon` for the leading visual, `label` for the visible row text, `count` for a trailing count chip, and `endIcon` for a trailing visual. A row's expand/collapse control (`ListItemDisclosureTrigger`) is a separate sibling, never a child — a button can't nest inside this `<button>`.

PropTypeDefaultDescription
labelstringVisible row label.
startIconReactNodeElement rendered before the label, usually `<Icon icon={...} />`.
countReactNodeTrailing count chip, e.g. an unread total. `0` and `""` are omitted (no empty/zero chip); pass a non-empty node (or the string `"0"`) when a zero must stay visible.
endIconReactNodeTrailing visual after the count, usually `<Icon icon={...} />`. Part of the primary hit target — not a separate control. Put interactive trailing actions (disclosure, menus) as siblings of this button, not here.

ListItemCounter

The row's trailing count chip (e.g. an unread count).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

ListItemDisclosureTrigger

A row's expand/collapse control — Base UI `Collapsible.Trigger` grafted onto the styled trailing-edge element (rule 1a), with the shared rotating caret baked in, and registered as a roving `CompositeItem` so it participates in the parent `List`'s one-tab-stop / arrow-key contract (same as `ListItemLink` / `ListItemButton`). Compose as a sibling of the row's primary inside a `ListItem`, itself inside the `Collapsible.Root` that also wraps the nested `List` panel — same shape as `ListSection`, just scoped to one row instead of a whole heading.

PropTypeDefaultDescription
aria-labelstringDefines a string value that labels the current element. Required: the trigger has no visible text (just the caret), so it must be labeled. @see aria-labelledby.

ListSection

A ready-made collapsible list section: a muted heading that toggles its body, with the disclosure chevron (points inline-end while collapsed, rotates down when open). Pass `label` for the heading and `children` for the body (typically a `List` of rows); forward `defaultOpen` (uncontrolled) or `open` + `onOpenChange` (controlled) to drive it. Set `indicator={false}` to omit the chevron. Children sit in `ListSectionPanelContent` (heading-to-row gap only) inside the height-animating panel — list rows own their own horizontal padding, so this does not wrap `CollapsiblePanelContent` (that part carries Collapsible's Figma panel inset + prose).

PropTypeDefaultDescription
label(required)stringThe section heading shown in the toggle.
childrenReactNodeThe section body — typically a `List` of rows.
indicatorbooleantrueWhether to show the rotating disclosure chevron at the heading's inline-end.
renderReactElement<unknown, string | JSXElementConstructor<any>>Element to graft the styled frame onto instead of the default `div` — element form only, the callback form's `state` isn't wired through the styled frame.