Artifact Shell
Artifact / document index
What it is
The page shell for an artifact or document index: a sidebar that navigates the library, a header carrying the index's name and its type facets, a search field of its own, and a scrolling grid of everything the product has generated. It is a block, not a component — it owns arrangement and nothing else. B1 fills the sidebar, A5 renders the facets, J4 renders every card, A3 buckets those grids by recency, K1 holds a passage that has not been filed yet, and L1 covers both the empty index and the emptied one. Each of them keeps its own props, its own state model and its own accessibility contract.
Why it matters
Claude Artifacts and the Manus Library converge on the same four regions, and on the same unusual card: a text excerpt rather than a thumbnail. That is the decision worth copying. Artifacts are mostly text, and the titles they arrive with are auto-generated and frequently wrong, so the first lines are the only field that reliably identifies one — which is why the excerpt is the card's largest text, the accessible name of its link, and the field this shell's search reads first. The second decision is that the grid's unit is the originating session, so an artifact stays one heading away from the conversation that produced it; an index that flattens that link turns a workspace back into a downloads folder.
Observed in: Claude Artifacts · Manus Library
Anatomy
- 1
data-region="sidebar"B1 app-sidebar holding whatever navigates the library, or L1 when nothing does. - 2
data-region="header"The sidebar trigger, the index title, and A5's type facets. Filter and header are one region. - 3
data-region="search"A labelled search field over the whole index, plus the live result count. - 4
data-region="artifact-card-grid"The scroll container: A3 date buckets of J4 sessions. Named, and its own tab stop. - 5
artifact-shellRoot. Contains its own fixed descendants so the shell can be embedded. - 6
artifact-shell-titleThe index's heading, inside the header region. - 7
artifact-shell-result-countLive region reporting how much of the index the filter and search left showing. - 8
artifact-shell-draftK1 ai-doc-block: the passage that has not been filed yet, above the buckets. - 9
artifact-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 generated documents are objects in your product rather than attachments to a conversation. `groups` is the whole index — date buckets on the outside, J4 sessions inside them — and everything else is scoping: `activeType` for the facets, `query` for the search, both controlled or uncontrolled as you prefer. The facets are derived from the artifacts you pass, so there is no facet list to keep in step with your data. Pass `draft` when something has just been generated and is still waiting on a Keep or a Discard: it renders above the index with K1's own verbs and is exempt from the filter, because it is not in the index until it is kept. Turn `localSearch` off when the query round-trips to a server and `groups` already arrives filtered. 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
Let the excerpt identify the artifact — it is the field people recognise, and the one they will search for.
Brand audit for Northwind1Bucket by recency on the outside and keep the originating session on the inside, so a card stays linked to its conversation.
Today
Brand audit for Northwind1Mount 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 swap the text card for a thumbnail because the grid looks emptier without one — a file-type glyph carries no information and the generated title underneath it is the least reliable field on the card.
Untitled document (3)
Edited 4 minutes ago
Don't leave J4's own facet row on while the header renders facets too; two pieces of state over one index is how a badge and its filter start disagreeing.
Brand audit for Northwind2
Accessibility
- Keyboard
- The shell's own tab order is: the sidebar trigger, then every facet chip in turn, then the Filters button if you passed `onOpenFilters`, then the search field, then the grid region itself, then everything inside it. The sidebar's slots come before all of that.
- The grid carries `tabIndex={0}` on purpose — it is the scroll container, so it is a real tab stop that can be scrolled with the arrows. It does not skip the cards beyond it; every card link is still its own stop after it.
- A5's chips have no roving tabindex, so the facet row is one Tab per chip. An index with six types costs seven presses ("All" included) before the search field, and Left/Right do nothing.
- Cmd/Ctrl+B toggles the sidebar from anywhere on the page, including while you are typing in this shell's search field — the vendored `SidebarProvider` binds it to `window` and calls `preventDefault()` unconditionally.
- Nothing focuses the search field by shortcut: there is no "/" handler and no skip link into the index. Reaching a card from the page body means tabbing the whole header.
- With `collapsibleSessions`, each session header adds a tab stop inside the grid, activated with Space or Enter.
- 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
- Only the grid is a landmark: `<section aria-label={gridLabel}>`, "Artifacts" by default. The header and search regions are plain `div`s carrying `data-region` for styling and tests, so they are not navigable as landmarks.
- The index title renders as a hard-coded `<h1>`. Embed the shell in a page that already has one and you get two; the level is not configurable.
- The result count is a `role="status"` that is always mounted, which is the correct shape and is what makes "the filter emptied the page" audible. Its text is recomputed on every keystroke, though, with no debounce — typing a seven-letter query queues seven announcements.
- The no-results empty state also carries `role="status"`, and it mounts at the moment it becomes true. So an emptying search fires two live regions for one event, and the second one breaks the always-mounted rule the count deliberately follows.
- The search field has a real visually-hidden `<label>` from `searchLabel`, so its name survives typing; the magnifier is `aria-hidden` and the placeholder is an example rather than the name.
- A facet chip announces as a pressed or unpressed toggle button named "Markdown 3" — the count is inside the chip, so it fuses into the name. The row is a `role="group"` named by `filterLabel`, not a radio group, so nothing announces that the facets are mutually exclusive even though this shell enforces that in its handler.
- The draft is its own `<section>` labelled by an `<h2>` from `draftLabel`, and the K1 block inside it brings its own persistent `role="status"` — so mounting a draft announces "This block is generated and waiting on your decision" alongside whatever the result count is saying.
- Date buckets are `role="group"`s named by their label and session groups are named `<section>`s inside them, so the index is navigable by region even though it exposes no headings below the `<h1>` and `<h2>`.
- The sidebar trigger is named "Toggle Sidebar" and carries no `aria-expanded`, so the sidebar's state is never announced in either direction.
- 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
- Nothing in the shell moves focus. Searching, switching a facet, and emptying the index all leave focus exactly where it was, so the reader has to go looking for the result count to learn what happened.
- Collapsing the sidebar while focus is inside `sidebarPromo` drops focus to `<body>`: that slot is removed from the layout at icon-rail width rather than hidden. Focus inside the nav survives the same collapse.
- The grid region carries its own `focus-visible` ring. The facet chips do not — A5's toggle ships no focus utility, so what paints on them is the user agent's `outline: auto`, recoloured by the global `outline-ring/50`. Two visibly different treatments in one row of controls, both real; the search field and the header actions inherit whatever the primitives and your own components supply.
Watch out for
- J4's card grid keys its columns off its own container width (D19), not the viewport, so even with the sidebar beside it the grid already sees its own narrower width and columns correctly with no help from this shell. If you fork this region, do not reintroduce a viewport-breakpoint override on the grid; that is the defect D19 fixed.
- The vendored sidebar's desktop container is
fixed inset-y-0 h-svh, right only when the shell owns the viewport. The root setscontain: layoutso the shell stays embeddable in a preview or a panel — keep that containment if you re-wrap the root, or the sidebar pins itself to the browser's left edge. The root also constrains that container to the shell's own height, which is what makessidebarPromoandsidebarFooterusable in an embedded shell. - A5's
FilterChiphas no notion of a selected group — each chip is an independent toggle with its ownaria-pressed, and there is nofilter-barprop that makes the row single-select or gives it a roving tabstop. This shell enforces single-select in its own handler; if you copy the pattern, enforce it in yours, and do not reach for a radio group instead, because the chips are also individually removable in other surfaces. - The type facets are derived from the artifacts you hand in, so a type with nothing in it has no chip. That is deliberate — a facet that always returns nothing is a broken control — but it means the facet row changes shape as the index changes, and a facet count reflects the search, not the active facet. When one type is all that is left, the chips disappear entirely rather than offering a choice with one option.
- The grid is the scroll container, which is why it carries
tabIndex={0}and an accessible name. Moving the overflow onto an inner wrapper without moving those two with it fails axe's scrollable-region-focusable rule and strands keyboard users outside the index. localSearchfilters the artifacts this shell was handed, matching the excerpt, the title and the type label. Leave it on for a server-backed index and the local pass filters the server's results a second time — an artifact matched on its body text will vanish because that text is not in its excerpt. Turn it off and the search field becomes a pureonQueryChangereporter.- K1's four verbs are the approval contract, and a draft that never resolves is a draft that blocks the index it is sitting on. Wire
draft.onKeepanddraft.onDiscardto real handlers that remove the prop; the shell renders whatever it is given and has no idea when the decision was made. - Every region mounts whether it has content or not, including the search field over an empty index. That is the point — a control that appears only once you already have data cannot teach anyone that it exists — so do not conditionally render the regions away on day one.
- 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, butswitcher,nav,sidebarPromoandsidebarFooterappear only once loading ends. - The header skeleton always reserves the facet row. An index with one artifact type and no
onOpenFiltersrenders no facet row once loaded, so its header shrinks by that row when the data arrives.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/artifact-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.