App shell
Mount application providers once, then compose a page header, content area, and responsive navigation.
6 min read
AppShell provides the shared theme, translation, and tooltip context. It does not draw your page header or navigation. Compose those separately with PageLayout, the adaptive header components, and ContentArea.
Mount the providers
Installation includes a complete entry point. A router-based application uses this composition:
import { AppShell } from '@tale/ui/app-shell';
import { RouterProvider } from '@tanstack/react-router';
// i18n and router are initialized by the host application.
<AppShell i18n={i18n} locale={{ mode: 'client' }} theme>
<RouterProvider router={router} />
</AppShell>;| Option | What it adds |
|---|---|
i18n | Required service instance, supplied to I18nextProvider. |
locale={{ mode: 'client' }} | Preference/browser locale detection and synchronization. onChange can load additional locale data; defaultLocale supplies a fallback. |
theme | The shared theme provider with the normal system-preference behavior. |
children | Your router or application content. |
The full optional stack is ThemeProvider, TooltipProvider, LocaleProvider, I18nextProvider, then locale synchronization and content. Do not add a TooltipProvider around each button; the outer provider shares tooltip timing across controls.
For a URL-driven locale, omit locale and synchronize the route's language through LocaleSync. Query clients, authentication, authorization, branding, and a toast viewport remain host responsibilities. AppShell imports the shared Inter fonts.
Compose the page
This is a labelled layout illustration. Its nested application header and controls are inert; inspect Code to see how the pieces compose without adding a second accessible application to this page.
| Piece | Responsibility |
|---|---|
PageLayout | Flex page and scroll container; wraps an optional header in StickyHeader. |
AdaptiveHeaderRoot | The title/action row, with optional border and responsive treatment. |
AdaptiveHeaderTitle | The page title, rendered as h1. |
ContentArea | Content spacing and width: page, narrow, or panel. |
Give flex ancestors a usable height and min-h-0 when the page should scroll inside them. PageLayout reserves scrollbar space to reduce sideways movement as row counts change. ContentArea includes clearance for mobile floating actions.
Choose one divider between header and content. Use showBorder for a plain header; avoid adding another border when a tab strip already supplies the divider.
Plan the mobile header
Adaptive headers coordinate through AdaptiveHeaderProvider. The desktop root alone is not a complete mobile header: the host must render the receiving AdaptiveHeaderSlot in its mobile chrome. The mobile treatment removes the desktop title from the accessibility tree and presents the active title through that slot.
Check a real phone-width page after composing the providers and slots. It should have one accessible h1, visible actions, and enough bottom clearance for any floating action bar. A desktop layout illustration cannot prove this integration for your service.
Add breadcrumbs
HeaderBreadcrumbs renders a labelled navigation list whose leaf is the current page's h1. Supply ancestor links or buttons through the crumb content and use HEADER_CRUMB_LINK_CLASS for their shared treatment. The component does not resolve application routes for you.
Below md, the trail collapses toward an immediate-parent back control. showImmediateParentOnMobile can preserve the parent's visible name where there is room. Long titles still need testing in the full header, including the action buttons beside them.
Add a section rail
SubPanel provides the bordered panel a section's navigation lives in, full height beside the page. Choose its width by the rows it holds: the default 224px for single-line rows, wide (256px) for longer labels, and list (280px) when each row carries a second line of context. It is hidden below md, so the host needs a mobile navigation path, such as a tab strip or a list screen of its own.
import { SubPanel, SubPanelHeader } from '@tale/ui/sub-panel';
import { SubPanelRowLink, SubPanelSectionHeader } from '@tale/ui/sub-panel-list';Head the panel with SubPanelHeader: the section's name and at most one or two actions, in the same h-13 row as the page header beside it, so the two bottom rules meet as one line. The caller supplies rows and scrolling. Use SubPanelRowLink for destinations and SubPanelSectionHeader for groups. For a custom row, reuse SUB_PANEL_ROW_CLASS and useSubPanelRowTreatment rather than reproducing selection styles.
When the section's navigation is a fixed set of pages, SectionNavPanel assembles the whole panel: the list-width frame, the header, and one highlight that glides to the open page instead of blinking between rows.
import { SectionNavPanel, SectionNavRow } from '@tale/ui/section-nav';
<SectionNavPanel title="Settings" ariaLabel="Settings" activeKey={activeHref}>
<ul className="flex flex-col gap-1">
<SectionNavRow href={accountHref} label="Account" icon={UserRound} active={activeHref === accountHref} />
</ul>
</SectionNavPanel>activeKey names the href of the row the highlight rests on; each SectionNavRow marks itself with that key. Pass layoutVersion when something moves rows without changing the open page, such as a disclosure opening above it. The highlight comes from useSlidingIndicator (@tale/ui/use-sliding-indicator), which you can use directly for any control whose selection should glide: the item carries data-indicator-key, the hook measures it against its positioned container, and the indicator moves with a CSS transform that respects reduced motion.
For a panel list of your own, draw that highlight with SlidingHighlight (from @tale/ui/section-nav) inside the list's positioned scroller, and let the rows keep only their text treatment above it (relative z-10, no fill of their own):
import { SlidingHighlight } from '@tale/ui/section-nav';
import { useSlidingIndicator } from '@tale/ui/use-sliding-indicator';
const { containerRef, ...indicator } = useSlidingIndicator<HTMLDivElement>(openKey, rowOrder);
<div ref={containerRef} className="relative overflow-y-auto">
<SlidingHighlight indicator={indicator} />
{rows}
</div>The hook applies one motion to every highlight: with nothing active it fades out where it stood, it lands on the next active item with a fade instead of sliding in from a corner, and it glides only between two places the reader can see. Take its transitionClassName rather than a transition of your own, so the rail, the segmented controls and the panels all move alike.
Head a conversation page
ThreadHeader (@tale/ui/thread-header) is the title row of every conversation-shaped page — a chat, a task's discussion, a customer conversation — so each reads the same way: controls before the identity (a panel toggle, a phone's back button), a 32px identity mark, the title, one line of context, and the page's actions.
import { ThreadHeader, ThreadHeaderSeparator } from '@tale/ui/thread-header';
<ThreadHeader
leading={<ContactInitials label={contact} />}
title={<h1>{subject}</h1>}
meta={
<>
<span>{contact}</span>
<ThreadHeaderSeparator />
<span>{age}</span>
</>
}
actions={<AssignButton />}
/>It keeps the page header's h-13 height and bottom rule; pass floating to draw it over scrolling content without the rule, as the chat does. The title slot takes a heading or an in-place editor for the name — the page supplies its single h1.
The row is a size container named thread-header, so what you put in it answers to the header's width rather than the window's: beside the rail and a section panel the header is only ~435px wide in a 768px window. Show an action's label from @xl/thread-header: and leave it icon-only below. Order the context line by importance, and when its items don't all fit, drop the last ones whole rather than truncating every item to a letter: a wrapping row clipped to one line (flex-wrap with a one-line height and overflow-hidden) keeps what fits and hides the rest, with each separator grouped with the item it introduces.
When the page opens another item in the same place — the next chat, task or conversation — let the swap read as new content rather than a flicker with useSwapFade (@tale/ui/use-swap-fade). Give it the open item's key and put its ref on the view that shows the item:
import { useSwapFade } from '@tale/ui/use-swap-fade';
const viewRef = useSwapFade<HTMLDivElement>(itemId);
<div ref={viewRef}>{item}</div>It fades opacity only, through the Web Animations API, so the view stays mounted and nothing it measures moves: a transcript restoring its scroll, a composer that keeps a draft. The first render never fades, nor does a change to nothing open, and reduced motion skips it. Pass fromEmpty: false when an item is born in place — a chat created by its first message is the same conversation continuing.
Start the application with a SkipLink targeting <main id="main" tabIndex={-1}>. Name each navigation landmark. Scrolling a rail to its active row should not steal the reader's initial keyboard position. Use the list-page or settings-page pattern for the content inside this shell.