Explore Shell
Community gallery
- ImageNeon city at dusk@lumen1.2k
- ImagePaper-cut forest@fold840
- VideoChrome jellyfish@drift3.4k
- ImageBrutalist greenhouse@slab612
- TemplateLaunch deck@studio9.1k
- ImageRisograph zine cover@press455
6 of 982 shown
What it is
The page shell for a community gallery: a destination rail on the left, a prompt bar docked above the feed, a strip carrying the two ways to narrow it, and a masonry feed of other people's work. It is a block, not a component — it owns arrangement and nothing else. The feed is J3 explore-gallery, the prompt bar is D1 media-prompt-bar, the type pills are A4 choice-chips, the rail is B4 modality-rail, and a tile opens into F3 asset-detail or J6 template-detail. Each of them keeps its own props, its own state model and its own accessibility contract.
Why it matters
Midjourney Explore, Spline Community, Pixlr and Canva templates all arrive at the same page, and the reason is the job: a community feed is browsed for surprise, not searched for a known item. That is why the feed is masonry. Equal-height rows make every row scan the same, which is exactly the quality a browsing surface needs to avoid — and it is why this shell and the personal archive next to it (O7 library-shell, dense and uniform by design) are two galleries rather than one component with a density toggle. The second decision worth copying is the prompt bar's position: it sits above the feed, so seeing something good and making your own version of it is one control away rather than a navigation step, and a tile's Remix action seeds that bar in place.
Observed in: Midjourney Explore · Spline Community · Pixlr · Canva templates
Anatomy
- 1
data-region="rail"B4 modality-rail — the product's destinations, with settings pinned below. - 2
data-region="docked-prompt-bar"D1 media-prompt-bar, docked above the feed. Remix seeds it in place. - 3
data-region="sort-tabs"Both axes, one region: a tablist for ordering, A4 choice-chips for type. - 4
data-region="masonry-feed"J3 explore-gallery, masonry only, or L1 when the feed is empty. - 5
explore-shellRoot. Reflects the live axes as data-sort and data-type. - 6
explore-shell-controlsThe control strip itself — the two axes side by side. - 7
explore-shell-scopeWhat the feed contains, shown when it offers no axes at all. - 8
explore-shell-more-itemOne A8 preview-tile in F3's more-like-this, derived from the feed. - 9
explore-shell-statusAt the top of the feed column, 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 primary object of your product is other people's work. `items` fills the feed and carries the two detail surfaces with it — give a tile an `asset` and it opens F3, give it a `template` and it opens J6, and the shell owns which one is open. `sorts` and `types` are separate props because they are separate axes: sorting reorders the whole feed, a type pill removes things from it, and both are yours to apply — the shell hands you the change and renders what you hand back. Ship facet counts on the type pills; without them every pill click is a gamble. The prompt bar's value is the shell's, because a tile's Remix action seeds it: read it through `onPromptValueChange` and act on `onPromptSubmit`. Everything else about the feed — `hasMore`, `loading`, `onLoadMore`, `totalCount` — is forwarded straight to J3 through `gallery`. 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 feed column, above the prompt bar, 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 tile heights vary. The uneven column is what makes a community feed worth scrolling.
- Neon city@lumen
- Paper forest@fold
- Chrome jellyfish@drift
- Greenhouse@slab
4 shown
Keep ordering and filtering as two named controls, so it is obvious they compose.
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 normalise every tile to one ratio — a uniform feed is an archive, and O7 library-shell already is one.
- Neon city@lumen
- Paper forest@fold
- Chrome jellyfish@drift
- Greenhouse@slab
4 shown
Don't merge the two axes into a single row of chips; it reads as one choice when it is two.
Accessibility
- Keyboard
- Front to back the shell is: the rail (one tab stop — B4 is a composite, so arrows move between destinations), the prompt bar's field and its buttons, the sort tablist (one stop, arrows move between sorts), one tab stop per type pill, then the feed.
- The type pills are the expensive part. A4 `choice-chips` announces `role="radiogroup"` but has no roving tabindex, so a strip of eight facets is eight tab stops between the sort tabs and the feed, on every visit to the page.
- The shell pins the open tab panel to `tabIndex={-1}` deliberately. Base UI makes an open panel focusable, which is right when the panel is the scroll container and wrong here — J3 keeps its own named, focusable, scrolling feed inside it, so the default would put two identical stops in front of the same content.
- Inside the feed every tile is an open button plus a Remix button, exactly as J3 ships them. There is no arrow-key navigation in the masonry and no shortcut onto a tile.
- Opening a tile's detail puts you in a dialog: focus is trapped there and Escape closes it. The shell itself handles no keys at all.
- 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 two axes announce as two named controls — the tablist takes `sortLabel`, the pill group takes `typeLabel` — which is the whole point of keeping them apart. Merged into one strip they would announce as a single choice.
- Type pills announce as radios with `aria-checked`, and a facet count is read as part of the pill's name ("Images 128"), so a count with no unit arrives as a bare number appended to the label.
- The feed region keeps J3's name. Because the shell mounts J3 with no sorts, the feed is a `role="region"` named by `gallery.feedLabel` — left unset, that is the default "Community feed".
- Turning J3's own prompt bar off costs one announcement that cannot be restored from outside. J3 points each Remix button at its internal field with `aria-controls`, and with `dockedPrompt={false}` that id is `undefined`, so the attribute is not emitted: Remix still says which tile it belongs to, and no longer says which field it fills.
- An empty feed is L1 substituted into the same region, and L1 has no live region — a filter that empties the feed replaces it silently. The shell adds no announcement of its own.
- With no axes at all the control strip renders `scopeLabel` as a plain sentence. It is text, not a status, so changing what the feed contains announces nothing.
- `data-sort` and `data-type` on the root are hooks for you and your tests. They carry no semantics and are never announced.
- 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
- The detail surfaces are mounted with `open` fixed true and no trigger element of their own. The shell wires no final-focus target, so where focus lands when a detail closes is whatever the dialog primitive restores to rather than something this block guarantees — wire it yourself if the tile has to regain focus.
- Choosing a thumbnail in F3's "more like this" swaps the open item rather than closing and reopening the dialog. The content changes in place, the thumbnail that had focus is replaced by a different item's, and nothing announces the swap.
- Remix seeds the prompt bar and leaves focus on the tile — and the bar is further away here than in J3 alone, because the sort strip and every type pill sit between the feed and the field.
- The rail, the pills, the tiles and the more-like-this thumbnails all carry their own `focus-visible` rings. The sort strip uses the tabs `line` variant, so its focus treatment comes from the tabs primitive rather than from the shell.
Watch out for
- J3 explore-gallery renders the docked prompt, the sort tabs, the type pills and the feed inside one root, and offers no slot on any of them — so a shell that needs them as separately addressable regions cannot get there by configuration. This shell mounts J3 feed-only (dockedPrompt off, both axes empty) and hosts D1 and the two control groups itself, handing the axes back through
sortandtypeso J3's root keeps reflecting them. If J3 later grows slot props for its controls, delete the shell's copies rather than keeping both. - Turning J3's own prompt bar off costs one thing that cannot be restored from outside: its Remix buttons point at the internal bar with
aria-controls, and that id is not exposed. In this shell the Remix control still says which tile it belongs to in its accessible name, but it no longer announces the field it fills. Reinstating that needs apromptIdprop on J3. - The Remix seam is hand-wired. J3's
onRemixseeds J3's own prompt state, which is inert here — the shell re-establishes the seeding through the same callback. Passprompt.valueexpecting it to win and you will break the one interaction this block exists for; usepromptValueinstead. layoutis deliberately not forwardable. J3 supports a row layout and this shell refuses it, because the spec's whole first line is masonry — if you want equal-height rows you want O7 library-shell, not this with a prop flipped.- J3's masonry column count steps at viewport breakpoints, not container width, exactly as J4's grid does. The rail is only 92px so the drift is small here, but a shell that adds a second panel beside the feed will over-column it.
- A feed with nothing in it is not J3's empty state — J3 renders an empty list and a "0 shown" status, which reads as a broken page. The shell substitutes L1 in the same region, so the region stays mounted and the emptiness is deliberate. Override it with
emptyif your copy should say something more specific. - B4 has no empty affordance of its own, so a rail with no items renders as an empty 92px column. Supply at least the destination the user is currently on.
- Base UI makes an open tab panel a tab stop, which is right when the panel is the scroll container and wrong here — J3 keeps its own named, focusable, scrolling feed inside it. The shell sets the panel to
tabIndex={-1}; if you re-wrap the feed region, keep exactly one tab stop in front of the feed or keyboard users pay for the duplicate on every page. - The sort strip uses the tabs
linevariant, not the default segmented one. The default paints the listbg-mutedand the trigger's resting colour on that surface is under 4.5:1 — the same pairing this registry keeps re-shipping. Restyle the strip and re-measure it. - The rail skeleton is B4's width, 92px, written into this shell as
w-23rather than read from B4. If B4 changes width, this skeleton falls out of step with it, and that class is the one to change. - The sort strip's skeleton reserves tabs and pills only for the
sortsandtypespassed while loading. Pass them if the loaded feed will offer them, or the strip changes height when the data arrives. - The prompt-bar skeleton reserves D1's context-chip row only when
prompt.contextChipsis passed while loading. Pass it if the loaded prompt bar will show chips, or the bar grows by that row when the data arrives.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/explore-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.