Home Shell
App Home / launcher
What it is
The page shell for an app home or launcher: a sidebar, a title bar, a large composer above the fold, and beneath it the bands that lead back into work you have already done — starters, features, recents, and things worth trying. It is a block, not a component. It owns arrangement and nothing else: nine registry components fill the five regions, and each of them keeps its own props, its own state model and its own accessibility contract.
Why it matters
YouAI, Descript, Zapier, CapCut and Spline all open on the same page, in the same order: composer, then starters, then features, then recents, then inspiration. It is the most consistent archetype on the reference board, and the order is the archetype — so it is hard-coded here rather than offered as a prop. Two decisions follow from it. The composer is the only emphasised element on the page, which is what makes a launcher feel like an invitation rather than a dashboard; every band below it is deliberately quiet. And the whole page is the recents grid's empty state on day one, which is the version most new users actually see — so every band stays mounted when it has nothing in it, and explains the nothing, rather than collapsing into a page that teaches a new user only that the product is empty.
Observed in: YouAI · Descript · Zapier · CapCut · Spline
Anatomy
- 1
data-region="sidebar"B1 app-sidebar around whatever nav this product has, or L1 when it has none. - 2
data-region="topbar"The sidebar trigger plus B7 app-topbar, with M2 credits-indicator in its trailing slot. - 3
data-region="hero-omnibox"C1 hero-omnibox, and C2 suggestion-chips directly under it — starters belong to the composer. - 4
data-region="feature-cards"C3 feature-card-row, or L1 when there are no features. C3 has no empty affordance of its own. - 5
data-region="recents-grid"C4 recent-grid, which owns its own in-grid empty tile — that tile is the day-one page. - 6
home-shell-inspirationC5 recommendation-card, last. A slot rather than a region: the manifest declares five. - 7
home-shellRoot. Contains its own fixed descendants so the shell can be embedded. - 8
home-shell-pageThe scrolling column. Everything below the topbar lives here. - 9
home-shell-headlineOptional page h1 above the composer. - 10
home-shell-startersThe C2 chip row, inside the hero region. - 11
home-shell-recommendationsThe C5 card grid, inside the inspiration section. - 12
home-shell-statusUnder the topbar, only whenstatusis passed. Holds M6 or the vendored Alert; the shell adds no live region of its own. - 13
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 your product's front door is a prompt rather than a document list. Everything is a prop: `suggestions` fills the starter row, `features` fills the card row, `recents` fills the grid, `recommendations` fills the inspiration band, and `omnibox` is forwarded whole to C1. The composer's text is the shell's, not C1's — pass `promptValue`/`onPromptChange` to control it, and leave both off to let the shell hold it. That is deliberate: selecting a starter chip has to write into the composer, and it must never submit or navigate. Put the balance in `credits` and it renders in the title bar, which is where an app-level cost signal belongs. Give `recentsEmptyAction` the same verb as your primary action ("New project", not "Get started") — L1 has no CTA of its own precisely so that a generic one is never the path of least resistance. Pass `status` for a message about the whole surface (offline, reconnecting, a failed save, an expired session, a rate limit): it renders under the topbar, 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 a starter chip fill the composer and stop there, so the prompt can still be edited before it runs.
Keep the recents band mounted when it is empty — its own tile is the day-one page, and it is what teaches a new user the band exists.
RecentsNo recent projectsProjects you open or create will show up here.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 wire a chip to submit. A starter that launches a run turns a suggestion into a decision nobody made.
Don't hide a band because it has nothing in it; a region that appears from nowhere later cannot teach that it exists now.
Popular featuresGenerate images · Edit video · Clone a voice
Accessibility
- Keyboard
- The shell binds no keys of its own. Its tab order is the composed components in DOM order: the sidebar's contents, the sidebar trigger, the topbar's actions and credits, then the composer, the starter chips, the feature carousel, the recents tiles and the recommendation cards.
- The scrolling page column has `overflow-y-auto` and deliberately no `tabIndex`. It gets away with that because C1 is inside it and is focusable in every one of its states, `locked` included, so axe's `scrollable-region-focusable` rule has a target. Reorder the column so the composer is not in it and that stops being true.
- The feature carousel is a `role="region"` with its own Left/Right handling plus two arrow buttons. Those arrows are two tab stops that exist whether or not the row actually overflows.
- A starter chip is `type="button"` on purpose, so a chip inside a form can never submit it. Enter and Space fill the composer and stop there. The overflow affordance beside the chips is a real `<a>`, so Enter follows it and Space does not.
- 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
- Four of the five bands are `<section>` elements with an `aria-label`, which makes each a named region landmark — "Start something new", then the features, recents and recommendations labels. Landmark navigation is the only structural navigation this page has.
- It is the only one because the band titles are not headings: A12 `section-header` renders its title as a `<span>`, so "Popular features" and "Recents" are visible text and nothing more. The page's single `h1` is `headline`, and it renders only if you pass one — omit it and the page has no heading at all.
- A starter chip's accessible name is its `suggestion` string, always: the leading icon and the leading thumbnail both sit inside an `aria-hidden` span. Selecting one writes into the composer and announces nothing, so whoever pressed it gets no confirmation that anything happened.
- The recents tiles are the weak point. A8 `preview-tile` renders a `below` label as a *sibling* of its frame button, and C4 uses `labelPlacement="below"` in grid layout and `"none"` in list layout — so the button that opens a project takes its name from the thumbnail alone. Give every thumbnail real `alt` text or the band is a row of unnamed buttons.
- Nothing in the shell is a live region. Filling the composer, collapsing the sidebar and paging the carousel all happen silently.
- M2 `credits-indicator` announces its own text and the shell adds no wording around it, so the balance arrives with whatever name M2 gives it and nothing identifying it as an app-level cost signal.
- 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
- Selecting a starter chip fills the composer and leaves focus on the chip, so the person has to Shift+Tab back into the field they just populated, past every chip that came before it. Move focus to the composer inside `onSelectSuggestion` if the chip is meant to be a shortcut rather than a stepping stone.
- Nothing else in the shell moves focus. Collapsing the sidebar keeps focus on the trigger, and the carousel arrows scroll without moving it.
- Focus styling is entirely the composed components'. The starter chips derive their ring from `buttonVariants`, which is what keeps the overflow link visually identical to a real chip; the recents tiles ship their own `focus-visible:ring-2`. The shell paints no focus style and adds no focusable element of its own.
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, a panel or an app region — if you re-wrap or restyle the root, keep that containment 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. - The composer's value belongs to the shell, not to C1, because a starter chip has to write into it. Passing
omnibox.valuedoes nothing useful — the shell overwrites it. UsepromptValueandonPromptChange, or leave both off and let the shell hold the text. - B7 has no leading slot, so the sidebar trigger is a sibling of the topbar and the row, not the header, draws the bottom rule. It also means the topbar's
actionsare merged rather than replaced: the shell spreads yourtopbarfirst and then rebuildsactionsas yours followed by the credits indicator. Passingactionsand expecting the credits chip to disappear will not work — omitcreditsinstead. - M2 paints a
bg-mutedpill and puts atext-muted-foreground"Top up" control inside it. That is the 4.34:1 pairing this system keeps re-discovering, in the cross-component shape the token check cannot see: the failing element is inside M2, so noclassNameon the call site reaches it. The shell rebinds--muted-foregroundon the chip instead, which the child resolves against. Keep that rebind if you restyle the topbar. - C3 has no empty affordance, so an empty feature row renders as a carousel with two dead arrows and nothing between them; the shell substitutes L1 there. C4 does have one, and the shell deliberately does not substitute — replacing C4's tile with a generic L1 would lose the grid's rhythm and the CTA slot the spec asks you to fill with your own verb.
- C3 now positions its own carousel arrows inside the row's own box (
left-2/right-2), so the shell rendersFeatureCardRowunadorned and needs no gutter of its own. If you fork this region, do not reintroduce an inset for the arrows; that was a workaround for a defect C3 no longer has. - C4 now keys its columns off its own container width (D19), not the viewport, so a sidebar open beside a wide window no longer makes it over-column — the grid already sees its own narrower width. If you nest this shell inside something narrower still, no override is needed; restyling the grid rather than the wrapper is only a concern if you reintroduce a viewport-keyed breakpoint yourself.
- C2's chip row is a Base UI
ScrollArea, and its viewport schedules a timer that callsgetAnimations()on itself. jsdom has no Web Animations API, so in a test long enough for that timer to fire it throws — after the test that triggered it has already passed. The suite goes red with every assertion green. The shim now lives invitest.setup.ts, paired withBASE_UI_ANIMATIONS_DISABLED— defininggetAnimationsalone moves every Base UI unmount off its synchronous branch onto a microtask, which breaks any test asserting the DOM straight after a panel swap. Whether the timer fires is a function of suite length, not of what you did wrong. - C5 requires
onDismissand renders nothing at all oncedismissedis true, so the inspiration band is only as durable as the state you feed back into it. Wire dismissal to something that remembers, or a card the user hid will be back on the next render. - 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, but anything you pass toswitcher,nav,sidebarPromoorsidebarFooterappears only once loading ends. - The hero skeleton reserves a headline and a row of starters only when
headlineandsuggestionsare passed. Pass them while loading if the loaded page will show them, or the hero grows when the data arrives.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/home-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.