Records Shell
Project / scenario list
What it is
The page shell for a list of things that run: projects, scenarios, zaps, workflows. A product rail on the left, a header carrying the name of the list and the button that adds to it, a row of filters and a sort control, and then the rows themselves — folders first, then the records, each with its enable toggle and its last run in plain words. It is a block, not a component: it owns arrangement and nothing else. The rail is B1, the folders are J1 in folder mode, the rows are J5, the filters are A5, the empty cases are L1 and the rating is N1, and every one of them keeps its own props, state model and accessibility contract.
Why it matters
Make, Zapier, n8n and Spline all land on the same page, and the reason is that these objects execute. A stored-asset browser can afford to be a wall of thumbnails; a list of live automations cannot, because the two questions you arrive with are "is this on?" and "did the last run work?". So the enable toggle is the primary control and it sits in the row — never behind an overflow menu, where switching an automation off becomes two clicks and a guess — and the run status sits in the subtitle beside the timestamp, because "last run failed" and "two hours ago" are one sentence. Everything else on the page is in service of finding the row you want: folders above, filters and sort across the top, a search that covers both lists.
Observed in: Make scenarios · Zapier zaps · n8n workflows · Spline projects
Anatomy
- 1
data-region="sidebar"B1 app-sidebar. Product navigation, or L1 when nothing is pinned. - 2
data-region="header"Sidebar trigger, list name, record count, and the create action. - 3
data-region="filter-sort"A5 filter-bar chips plus the filters button, with the sort select trailing. - 4
data-region="record-rows"The scroll container: J1 folders, then J5 records or L1, then N1. - 5
records-shellRoot. Contains its own fixed descendants so the shell can be embedded. - 6
records-shell-countHow many records the list currently holds. - 7
records-shell-sortThe sort control.records-shell-sort-triggeris the named button. - 8
records-shell-feedbackN1 with its caption, at the foot of the record region. - 9
records-shell-statusUnder the header, only whenstatusis passed. Holds M6 or the vendored Alert; the shell adds no live region of its own. - 10
shell-skeleton-regionOne per region whileloading: hidden from assistive tech and sized like the loaded region. It carriesdata-loading-region, as does each loaded region's box, which is what the loading twin measures.
How to use it
Reach for it when the objects your product lists are runnable rather than stored — if they are stored, O7 is the shell you want, and using this one would promise an execution model you do not have. Everything is a prop or a slot: `records` fills the rows and is forwarded whole to J5, `folders` fills the folder table, `filters` fills the chips, and `nav` fills the rail. Wire `onEnabledChange` before anything else; it is the primary control of the page and a shell rendered without it is a screenshot. Give every record a `runState` and a `lastRun` — a row with neither says nothing about whether the automation is healthy, which is the only thing the list is for. `search` and `onSearchChange` are controlled and the shell does no filtering: hand it the folders and records that already match, exactly as J1 documents. Pass `status` for a message about the whole surface (offline, reconnecting, a failed save, an expired session, a rate limit): it renders under the header, only when given, and holds M6 or the vendored Alert. Pass `loading` for first paint: every region draws a skeleton at its loaded size, the root is marked busy, and nothing is mounted that could take focus.
Do
Keep the enable toggle in the row, named after the record it enables.
Scenarios (example) Record Apps Enabled Actions Daily briefing digestLast run OKLast run 4 min ago- Gmail
- Notion
Lead sync to CRMLast run failedLast run 2 hours ago- HubSpot
Put run status in the subtitle, as an icon shape and a word, beside the time it happened.
Scenarios (example) Record Apps Enabled Actions Lead sync to CRMLast run failedLast run 2 hours agoRevenue ops- HubSpot
Mount the command palette once, at the root of your app, and keep it out of the shell: it is not a shell slot, and a palette mounted in each shell binds its shortcut once per surface.
Don't
Don't demote the toggle into an overflow menu — turning a live automation off is the fastest thing this page has to do.
- Daily briefing digestLast run 4 min ago
- Lead sync to CRMLast run 2 hours ago
Don't reduce run status to a coloured dot; colour alone carries no meaning, no timestamp and no accessible name.
- Lead sync to CRMRevenue ops
Accessibility
- Keyboard
- Tab order runs rail contents → sidebar trigger → header actions → create → filter chips → add-filter chip → Filters → sort → the record region itself → folder search → folder rows → record rows → feedback. The rail comes *before* the trigger that collapses it, because the nav is a DOM sibling ahead of the header — this note read the other way round until a walk with a filled rail measured it, and the old order looked right only because the default `nav` is an L1 with nothing focusable in it. A shell with six filter chips, ten folders and twenty records is well over sixty stops from the rail to the last row.
- The record region is a `section` with `tabIndex={0}`, so it is a tab stop in its own right — the one that makes the scroll container reachable without a mouse wheel. Expect a Tab press that appears to focus nothing visible before the first folder control.
- Mounting the shell registers a **window-level** Cmd/Ctrl+B listener that toggles the rail. It is global, not scoped to the shell, so it fires from anywhere on the page and two shells mounted together both respond to one press.
- An applied filter chip is two stops, not one — A5 renders the toggle and the remove X as siblings — so `filters` with `onRemove` on every entry doubles that section of the tab order.
- J1's list/grid switch is suppressed with `display: none`, which takes it out of the tab order and the accessibility tree rather than just hiding it. There is no dead stop where the switch would have been.
- Nothing here handles arrow keys. Rows do not navigate, folders do not navigate, and the only components with arrow-key behaviour are the ones that bring it themselves: the sort `Select` and each row's overflow menu.
- While `loading`, the shell mounts none of its controls, so there is no tab stop inside it until the data arrives; a control you pass in `status` is the only one.
- Screen reader
- The `<h1>` in the header is the shell's only heading. J1's "Folders" title comes from A12 `section-header`, which renders a `<span>` — so heading navigation goes straight from the page title to the end of the page, past both lists.
- The record region is a named landmark: `aria-labelledby` points at the same `<h1>`, so it announces as a region called whatever you passed as `title`. That name is what lets the scroll container carry `tabIndex` without tripping axe's scrollable-region-focusable rule.
- The record count beside the heading is a bare `<span>` of digits with nothing naming it. "Scenarios, 24" reads acceptably in order and reads as a stray number if you land on it alone.
- The chip row is a `role="group"` named "Filter records", which is a fixed string — it does not say how many filters are applied, and nothing announces that applying one changed the list.
- The sort trigger's accessible name is `sortLabel` ("Sort by") while its visible text is the current option ("Last run"). The visible words are not part of the name, so a voice-control user asking for "Last run" does not reach it — say "Sort by" instead.
- The shell adds no live region of its own. Toggling a record, changing the sort, typing in the search box and filtering the list all announce nothing beyond the control the user touched; J5's switch reports its own state and that is the whole of it.
- Everything else is inherited untouched: J5's app clusters and switch names, A5's `aria-pressed` chips, J1's sr-only search label and per-row action names, and N1's `aria-pressed` thumbs.
- While `loading`, the root carries `aria-busy` and every skeleton is hidden from assistive tech, so a screen reader finds one visually hidden line, Loading, plus anything you pass in `status`.
- The shell puts no live region around `status`, and the wrapper mounts together with its content, so a message given on `status`'s first render is not reliably announced on arrival: a live region announces changes to a region already in the DOM, not its own insertion. M6's countdown updates that follow are announced, and the vendored Alert's default assertive alert is generally announced on mount, so choose the one whose announcement fits, or keep `status` mounted and change its content for a later message. Inside a busy root, a screen reader may hold an announcement until `loading` clears.
- Focus
- Collapsing the rail does not move focus — the trigger keeps it — but the rail's contents leave the tab order while collapsed, so a Tab press from the trigger lands in the header instead of the nav.
- Emptying the list swaps J5's table for L1 `empty-state`. If focus was on a row control when the last record went away it falls to `<body>`; the same applies to the folder table.
- The scroll region has no `focus-visible` style of its own, so it falls back to the user agent's `outline: auto 1px`, recoloured by the global `outline-ring/50`. It is thin next to the ring-2 the controls draw, which is worth knowing before you decide whether the arrow keys starting to scroll is the clearer signal. Three of A5's controls are in the same position — the chip toggle, the add-filter chip and the Filters button paint only the user agent's outline, while the remove X inside an applied chip has a real ring. Everything else inside (sidebar trigger, create, sort, folder names, row titles, switches, overflow triggers) carries a ring from its own component.
Watch out for
- The vendored sidebar's desktop container is
fixed inset-y-0 h-svh, which is right only when the shell owns the viewport. The root setscontain: layoutso the shell stays embeddable in a preview or an app region — if you re-wrap or restyle the root, keep that containment or the rail will pin itself to the browser's left edge. This shell exposes no promo or footer slot at all; O10's spec has no bottom-anchored region to fill. - J1 has no way to turn off its list/grid switch.
viewcan be pinned — the shell pins it tolist— but the control still renders and still invites a press that now does nothing, so the shell suppresses it with ahiddendescendant variant on J1's own class list. If you restyle the folder table, keep that variant or a grid of folder tiles reappears above a table of records. - J1 owns the search field and does no filtering. The shell surfaces it as the page search because it sits above both lists, but nothing narrows automatically:
onSearchChangetells you what was typed and you hand back thefoldersandrecordsthat match. Wiring the field without wiring the filtering produces a search box that does nothing, which is worse than having none. - J5 renders one
<table>and exposes no per-row slot, so there is nowhere to hang a per-record affordance the component does not already have — no per-row feedback, no per-row status sibling of the kind chat-shell renders under a thread. That is why N1 rates the list rather than a record. If you need per-record feedback, the fix belongs in J5, not in a fork of it here. - The record region is the scroll container, which is why it is a
sectionwithtabIndex={0}and a name taken from the header's heading. Moving the overflow onto an inner wrapper without moving those with it fails axe's scrollable-region-focusable rule and strands keyboard users outside the list. - The sort control's trigger renders the raw
valueunless it is given children, so an unlabelled select showsrecentinstead of "Last run". The shell resolves the label fromsortOptions; if you pass your own options make sure everyvalueyou might set also has an entry, or the trigger falls back to the word "Sort by". sortis optional-controlled andrecords,foldersandfiltersare not controlled at all — the shell holds no list state. It will happily render filter chips that areactiveover a record list that was never filtered, which looks correct and lies. Filter your data where it lives.- While
loading, B1 is not mounted, because it always renders its rail button and a loading shell mounts nothing to click. The sidebar skeleton takes its width from the sidebar provider's state instead, so it followsdefaultSidebarOpenand a Cmd/Ctrl+B toggle, butswitcherandnavappear only once loading ends. - While
loading, neitherheaderActionsnor the create action is mounted; the header reserves one placeholder for them when either is passed, and keeps its height because the bar is a fixedh-14.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/records-shell.jsonRequires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.