Library Shell
Personal archive
Today
Last week
What it is
The page shell for a personal archive: a faceted rail on the left, a header with search and the filters currently applied, and a dense grid of everything you have made. It is a block, not a component — it owns arrangement and two pieces of view state (thumbnail size, and which asset is open), and nothing else. The rail is J2 filter-panel, the header is J1 asset-library, the grid is F2 generation-grid rendering A8 preview-tiles into A3 date sections, the empty view is L1, and a tile opens F3 asset-detail. Each of those keeps its own props, its own state model and its own accessibility contract.
Why it matters
Midjourney Organize is the reference implementation, and the two things it gets right are the two things this shell exists to preserve. The first is density: an archive is scanned, not browsed, so the grid opens eight-up and thumbnail size is a control the reader owns rather than a decision the brand made — the same archive is a contact sheet for one person and a lightbox wall for another. The second is counts. A facet that does not say how many results it would leave turns every filter click into a gamble, and a rail of gambles is abandoned within a session; J2 makes the count a required field precisely so that cannot happen. Everything else follows from the third promise, that clicking any tile opens the full provenance record — which is what makes an archive worth keeping rather than a folder of files with unhelpful names.
Observed in: Midjourney Organize
Anatomy
- 1
data-region="facet-rail"J2 filter-panel — counted facets, saved searches, and the thumbnail-size control. Scrolls, so it carries its own tab stop and name. - 2
data-region="header"J1 asset-library's header, search field and A5 filter-chip row. Search lives here, which is why the shell has three regions and not four. - 3
data-region="dense-grid"F2 generation-grid at the density the rail selected, one A3 date-section per group, L1 in the first cell when there is nothing. - 4
library-shellRoot. A column below md, a row above it; both panes scroll independently. - 5
preview-tileA8, one per asset, carrying data-asset-id. Its own frame button is what opens F3 or toggles selection. - 6
asset-detailF3, mounted at shell level rather than inside a region — a lightbox over the archive, not a fourth pane in it. - 7
library-shell-statusAt the top of the content column, only whenstatusis passed. Holds M6 or the vendored Alert; the shell adds no live region of its own. - 8
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 primary object of your product is the pile of things a user has already made. Everything is a prop: `facets` fills the rail, `groups` (or `assets`) fills the grid, `search` and `onSearchChange` drive the header's field, and `openAssetId` opens the lightbox. Filtering is yours — the shell renders the rail and the chips, and hands you back the selection through `onSelectedFacetsChange`; the assets you pass are the assets it shows, so server-side and client-side filtering look identical from here. Give every facet an honest count, including zero: J2 disables a zero-count facet and says "no matches" out loud, which is worth more than hiding it. Leave `density` alone unless you are persisting the reader's choice — the default is deliberately the densest one — and pass `groups` rather than `assets` whenever your archive has any notion of when things were made, because a date-bucketed archive is navigable and a flat one is not. Pass `status` for a message about the whole surface (offline, reconnecting, a failed save, an expired session, a rate limit): it renders at the top of the content column, above 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
Give every facet its count, so a filter states what it would leave before anyone clicks it.
Filters
Open dense. An archive is a page you scan, and eight-up is what makes a day of work fit on one.
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 ship a rail of bare labels — an uncounted facet hides its dead ends until you have already clicked them.
Filters
Don't bring gallery density to an archive; four-up turns a week of results into a scroll session.
Accessibility
- Keyboard
- Both scrolling panes take a tab stop of their own: `data-region="facet-rail"` and `data-region="dense-grid"` each carry `tabIndex={0}` and an `aria-label`, which is what satisfies axe's `scrollable-region-focusable` rule. They are stops that do nothing but let the arrow keys scroll, so expect a dead Tab before the first control in each pane.
- Every applied facet is two tab stops in the header, not one. The mirrored `filter-chip` is given both `onClick` and `onRemove`, and both call the same `deselectFacet` — so the toggle and the X are adjacent controls with identical effects. Six applied facets is twelve stops between the search field and the grid.
- A tile is a single button. Enter and Space open the asset in browse mode and toggle selection in select mode; the two are never live at once, so there is no checkbox layered over the tile and no second control to reach.
- The grid has no roving tabindex and no arrow-key navigation — `role="list"` is for announcement only. Moving between tiles means one Tab per asset, however many the current density is showing.
- The lightbox is a dialog: Escape closes it and focus is held inside while it is open. Nothing else in the shell handles a key.
- The bulk-actions bar only exists once something is selected, so entering select mode changes the number of stops above the grid partway through a tab sequence.
- 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
- Three named regions: the rail is an `<aside aria-label="Filters">`, the grid a `<section aria-label="Assets">`, and the header a plain div carrying J1's own structure. The rail's landmark name is fixed — `filtersTitle` renames the heading inside J2, not the landmark around it.
- Facet counts are folded into each checkbox's accessible name by J2, so a facet announces as its label and its count together, and a zero-count facet is disabled and carries an sr-only "no matches". It is the one thing here a screen-reader user gets more reliably than someone skimming the rail.
- The mirrored chips escape the accessible-name trap that usually bites A5: `FilterFacet.label` is typed `string`, so `filter-chip` always builds a real "Remove {label} filter" name and the header never degrades into a row of identical "Remove filter" buttons.
- Every tile uses `labelPlacement="overlay"`, which puts `asset.name` inside the frame button — the only reason the tiles have names at all. A `below` label is a sibling of the button and would leave it nameless whenever the thumbnail is decorative, and A8 spreads extra props onto its wrapper rather than the frame, so an `aria-label` at the call site cannot rescue it either.
- A tile's button always reports `aria-pressed`, because A8 stamps it whenever `onSelect` is set. Right in select mode; in browse mode every tile announces as an unpressed toggle when it is really an open action.
- The lightbox announces the same name for every asset. F3's `DialogTitle` is a hard-coded, sr-only "Result detail", so opening the tenth tile sounds exactly like opening the first and the asset's own name is never spoken on open.
- Removing the last facet, changing density and a filter emptying the grid all happen with no live region anywhere in the shell. "No matches" is visible text; nothing announces arriving at it.
- 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
- Removing a facet chip unmounts the button that had focus and nothing restores it, so focus falls to `<body>` and the next Tab restarts from the top of the page. That is A5's documented behaviour and the shell does not intercept it — and it fires from either half of the chip, since both are wired to the same removal.
- Opening a tile moves focus into the lightbox and closing it returns focus to the tile that opened it. That is the one focus handoff in the shell that works without your help.
- The two pane tab stops are bare `tabIndex={0}` containers with no `focus-visible` class of their own, so they fall back to the user agent's outline — recoloured by the repo's global `outline-ring/50` and measured at `outline: auto 1px`. Thin against the ring-2 the controls around them paint, not absent. Add a ring if the panes should read as loudly as the controls; the shell does not.
- Below `md` the rail becomes a band above the header rather than disappearing, so its tab stop keeps its place in the order. Nothing about the keyboard path changes at the breakpoint.
Watch out for
- J1 asset-library has no header-only mode, and this shell needs exactly its header: title, actions, search field and the chip row. J1 is therefore handed no items, and three of its own affordances are suppressed by a documented call-site variant — the section-header count (which would read 0 above a grid of hundreds), the list/grid view toggle (there is no J1 body here for it to switch) and the leftover empty div. If you restyle the header region, keep that constant, and delete it the day J1 grows an exported header or a
body={false}prop. - The archive's counts live in the rail, not in the header. That is the spec's position — facet counts are what make a rail usable at scale — but it means there is no single "1,284 assets" figure anywhere in the shell. If your product needs one, put it in
headerActions; do not try to restore J1's own count, which counts J1's body and is always zero here. - A8 preview-tile renders a
belowlabel as a sibling of its frame button, so a tile whose thumbnail is decorative would have a button with no accessible name at all. Every tile here useslabelPlacement="overlay"for that reason. A8 also spreads its extra props onto its wrapper div rather than the frame, so you cannot fix a missing name with anaria-labelat the call site — the label prop is the only route. - A8's interactive frame stamps
aria-pressedonly when told to. It defaults toselectMode="toggle", which is right for a selectable cell and wrong for a tile that opens something —aria-pressed="false"is a claim about state the tile does not hold. This shell forwardsselectModealongside the handler it already branched on, so browse-mode tiles announce as plain buttons. If you build your own grid on A8, forward it too; the default is what every caller got before the prop existed. - Both panes scroll, so both carry
tabIndex={0}and a 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 archive. - Facet selection, search text, bulk selection and the open asset are all controlled or controllable. Rendering the shell with a
selectedFacetsthat never changes produces a screenshot, not an archive — the rail's checkboxes will tick and untick and the grid will never move. - The rail does not disappear below
md; it becomes a short scrolling band above the header. That is deliberate — a region that vanishes at a breakpoint cannot teach that it exists — but it costs vertical space on a phone. If you want a drawer instead, wrap the shell rather than unmounting the region, and keep thedata-regionmarker rendered. - F2's density prop is the only thing that separates dense from roomy, and it lives on one class list inside the grid. Restyling the dense-grid region's wrapper will not change the column count; pass
density, or drive it from the rail's thumbnail-size group as the shell already does. - The header skeleton reserves a taller title row only when
headerActionsis passed, and an applied-filter row only when facets are already selected, the same rules J1 follows once loaded. Pass both while loading if the loaded header will show them.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/library-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.