Skip to content
Component installs need the registry setup
VegaStack Design

list-page-01

A list page — search, a Status facet and Mine | Team over one set of records as a table or a grid of linked tiles, with Load more.

Last updated

Customers

Everyone you sell to, with their projects and status.

CustomerStatusProjectsUpdatedActions
Skyline HotelsPortlandActive2
Cobalt StudiosBostonPaused0
Granite LawBostonActive5
Delta WorkspaceSeattleProspect8
Lumen GalleriesMiamiActive0
Kestrel AirMiamiPaused12
Horizon DentalDenverActive9
Maple ClinicsMiamiActive6

What it is

A list page — FilterBar search, a Status facet and Mine | Team over the same records as a table or a grid of linked tiles, with Load more and three empty tiers. It is a block: you pull it once with shadcn add, and then you own it — nothing re-pulls it and no integrity hash tracks your edits, unlike the components it composes.

CustomerList in components/ holds the filters, both views and the empty tiers; sample-customers.ts is the stand-in data.

Install

npx shadcn@latest add @vegastack/list-page-01
Registry setup required
Run this command only after configuring the Base UI shadcn project, the @vegastack registry namespace, and the Cloudflare Access service-token headers in Install from the VegaStack registry.

This installs the block's own files plus the components it composes: app-shell, badge, button, data-list, dropdown-menu, empty, filter-bar, item, load-more, page-header, relative-time, skeleton, tabs, view-toggle.

Preview

Customers

Everyone you sell to, with their projects and status.

CustomerStatusProjectsUpdatedActions
Skyline HotelsPortlandActive2
Cobalt StudiosBostonPaused0
Granite LawBostonActive5
Delta WorkspaceSeattleProspect8
Lumen GalleriesMiamiActive0
Kestrel AirMiamiPaused12
Horizon DentalDenverActive9
Maple ClinicsMiamiActive6

Anatomy

  • AppShellPage › PageHeader h1 "Customers" with a "New customer" link, and under the title its tabs row: Mine | Team as default (pill) Tabs, with the Grid | List ViewToggle in view, pinned to the row's end.
  • FilterBar searchPlacement="start" — the search first, then the FilterBarFacets "Status: Any" and "Industry".
  • List — a DataList with getRowHref, so each row is a real link (the first cell, name over city, is the link; a click anywhere on the row follows it), a status Badge, a tabular-nums count, a RelativeTime, and rowActionsColumn for Edit and Archive.
  • Grid — the same records grouped by industry: an h2 in the section face over an ItemGroup grid (@sm:grid-cols-2 @4xl:grid-cols-3) of whole-tile links.
  • Both views page with Load more (loadMore on DataList, LoadMore under the grid).

Empty tiers

TierWhenCopyAction
Nothing yetthe scope has no records at allNo customers yet (Mine: You have no customers yet)New customer
No matchesfilters hide every recordNo matchesClear filters
Couldn’t loadthe first load failedCouldn’t load customersTry again

Keep the view and filters

Keep the last view and filters per person for the session, so Back returns to the same list. Storage can throw (a privacy mode, a full quota, a sandboxed frame) and can hold anything, so read it in an effect rather than a useState initializer (the server has no sessionStorage, and an initializer that reads it renders differently on the client), guard both directions, and accept only the values the page knows — anything else falls back to the default:

const KEY = "customers:list";
const VIEWS = ["list", "grid"] as const;
const SCOPES = ["mine", "team"] as const;
const STATUSES = ["Active", "Prospect", "Paused"] as const;
const oneOf = <T extends string>(
  allowed: readonly T[],
  value: unknown,
): value is T =>
  typeof value === "string" && (allowed as readonly string[]).includes(value);

const [view, setView] = React.useState<(typeof VIEWS)[number]>("list");
const [scope, setScope] = React.useState<(typeof SCOPES)[number]>("team");
const [status, setStatus] = React.useState<(typeof STATUSES)[number] | null>(
  null,
);
const [restored, setRestored] = React.useState(false);

React.useEffect(() => {
  try {
    const saved: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? "null");
    if (saved && typeof saved === "object") {
      const { view, scope, status } = saved as Record<string, unknown>;
      if (oneOf(VIEWS, view)) setView(view);
      if (oneOf(SCOPES, scope)) setScope(scope);
      if (oneOf(STATUSES, status)) setStatus(status);
    }
  } catch {
    // Unreadable or malformed: keep the defaults.
  }
  setRestored(true);
}, []);

React.useEffect(() => {
  if (!restored) return; // never overwrite the saved state with the defaults
  try {
    sessionStorage.setItem(KEY, JSON.stringify({ view, scope, status }));
  } catch {
    // Storage unavailable: the page still works, it just forgets.
  }
}, [restored, view, scope, status]);

A preset route is a path segment, never query parameters: /customers/mine renders the same page with its initial scope set to Mine. Make each preset its own static route, so there is nothing to parse or validate and it cannot collide with the /customers/[id] record route beside it (a static segment wins over a dynamic one):

// app/customers/mine/page.tsx
export default function Page() {
  return <CustomerList initialScope="mine" />;
}

initialScope is a prop you add to your copy of CustomerList, typed as the same "mine" | "team" union. The link someone followed wins over what the session remembered, so skip restoring scope when initialScope is set; their changes after that are saved as above.

Files

FileRegistry typeTarget
list-page-01/components/customer-list.tsxregistry:componentapp/list-page-01/components/customer-list.tsx
list-page-01/components/sample-customers.tsregistry:componentapp/list-page-01/components/sample-customers.ts
list-page-01/page.tsxregistry:pageapp/list-page-01/page.tsx

Do / Don't

Do
Keep one set of records behind both views, and give each empty tier its own copy and one action.
Don't
Let a view switch or a facet deselect to nothing, or show “No matches” when there is simply no data yet.

On this page