Skip to main content

MobileShell

Info:Shell
17

The touch-app frame: a sticky app bar, one scrolling column, and a bottom nav in thumb reach.

mobile phone touch app bar frame layout shell

Live demo

live · @xtyle/astro

MobileShell

The frame

A sticky app bar, one scrolling column, and a bottom nav in thumb reach. Scroll inside the phone: the bar and the nav hold their place while the column moves. The tabs are a real tablist, so arrow keys (and Home / End) move between sections.

Inbox

Deploy finished

Success:success

the 0.7 cut is live on staging

Review requested

Info:info

hover previews on Image

Build failed

Danger:danger

a backtick closed a template literal

New follower

neutral

someone starred the repo

Weekly digest

neutral

14 commits, 3 merged

Token drift

Warning:warn

two components consume a retired token

MobileShell is the phone counterpart to AppShell, and deliberately a separate frame rather than AppShell made responsive. AppShell is desktop chrome by construction: a three-row grid whose body is a left/main/right split with px-width resizable rails.

On a phone that model doesn't degrade so much as stop being the right shape, and bending one frame into both would make each worse. Desktop-IDE chrome and touch-app chrome are different interaction models, not one layout at two widths — so they are two components, and an app picks one at its entry.

The frame is three regions and nothing else: a sticky app bar (a heading, an optional brand, and an actions slot), a single scrollable <main> column, and a nav slot for the bottom nav, pinned within thumb reach. The shell renders that chrome itself through its fragment, exactly as AppShell does, so a mod can reshape the frame the same way it can reshape any other component.

The bar and the nav absorb the safe-area insets (env(safe-area-inset-*)), so on a notched phone the chrome takes the hardware and the content column between them is never the thing sliding underneath it. The column scrolls itself rather than the page, so the bar and the nav stay put while it moves.

When to use

How this component composes with the rest of the set.

Pair it with BottomNav in the nav slot; the two go together the way AppShell goes with Toolbar and Statusbar.
Reach for AppShell on the desktop and MobileShell on a phone, and choose between them at the app's entry rather than trying to morph one into the other at a breakpoint.
The column scrolls itself, so a long list belongs in the default slot and not in a nested scroller; a second scroll region inside it is the usual cause of a page that scrolls in two places at once.

Props

2 props, straight from the manifest.

PropTypeDefaultBindingsDescription
heading string
html svelte astro
The app bar's title. Named `heading` rather than `title`, which `HTMLElement` already owns (the native tooltip), so setting one can never quietly mean the other.
main-id string main
html svelte astro
The `id` of the `<main>` column, so a skip link can target it. Override when more than one shell renders on a page.

Anatomy

The named parts that make up the component, with their selectors.

bar

.xtyle-mshell__bar

The sticky app bar. Absorbs the top safe-area inset, so nothing sits under a notch.

--bg-1 --line --border-thin --space-3 --space-4

title

.xtyle-mshell__title

The bar's heading, truncated rather than wrapped so a long one can't grow the bar.

--fg-0 --text-lg --weight-semibold

actions

.xtyle-mshell__actions

The trailing controls in the bar; the heading is pushed to the start, so actions collect at the end.

--space-2

content

.xtyle-mshell__content

The one scrolling column, rendered as a real <main>. It scrolls itself, not the page, so the bar and the nav hold their place.

--space-4 --space-6 --ring --border-thick

nav

.xtyle-mshell__nav

The bottom nav region, usually a BottomNav. Its own bar absorbs the bottom safe-area inset.

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 17/17 consumed tokens produced default register: 299 tokens

Live coverage check against the xtyle-default register (derive(xtyleDefault, { anchors })coverComponent(manifest, register)). Every token this component consumes must be a key the algorithm produces.

--bg-1 --body-bg --border-normal --border-thick --border-thin --fg-0 --font-sans --leading-normal --line --ring --space-2 --space-3 --space-4 --space-6 --text-body --text-lg --weight-semibold

Slots

brand
html svelte astro

A mark rendered before the heading, e.g. a logo.

actions
html svelte astro

The bar's trailing controls.

default
html svelte astro

The scrolling content column, rendered into <main>.

nav
html svelte astro

The bottom nav, usually a BottomNav.

Accessibility

The content column is a real <main> landmark carrying the main-id a skip link targets, and it takes tabindex="-1" so focus can be moved into it without making it a tab stop of its own.
The bar and the nav absorb env(safe-area-inset-*), so no control ends up under a notch or a home indicator where a thumb can't reach it.
The chrome is the component's own fragment, so it renders through the same SSR path as every other component: the shell lays out and scrolls before (and without) the runtime.

Code

The frame

A sticky bar, one scrolling column, and a bottom nav. The shell renders the chrome; the slots take the app's content.

<xtyle-mobile-shell heading="Inbox">
	<xtyle-button slot="actions" variant="ghost" size="sm">Filter</xtyle-button>

	<!-- the one scrolling column -->
	<p>Whatever the section renders.</p>

	<xtyle-bottom-nav
		slot="nav"
		value="inbox"
		label="Sections"
		tabs='[{"value":"inbox","label":"Inbox","icon":"folder","badge":3},
		       {"value":"threads","label":"Threads","icon":"menu"},
		       {"value":"you","label":"You","icon":"gear"}]'
	></xtyle-bottom-nav>
</xtyle-mobile-shell>
<script lang="ts">
	import { MobileShell, BottomNav } from "@xtyle/svelte";

	let section = $state("inbox");
	const tabs = [
		{ value: "inbox", label: "Inbox", icon: "folder", badge: 3 },
		{ value: "threads", label: "Threads", icon: "menu" },
		{ value: "you", label: "You", icon: "gear" },
	];
</script>

<MobileShell heading="Inbox">
	{#snippet actions()}<button>Filter</button>{/snippet}

	<p>Whatever the section renders.</p>

	{#snippet nav()}
		<BottomNav {tabs} bind:value={section} label="Sections" />
	{/snippet}
</MobileShell>
---
import MobileShell from "@xtyle/astro/MobileShell.astro";
import BottomNav from "@xtyle/astro/BottomNav.astro";
---

<MobileShell heading="Inbox">
	<Fragment slot="actions"></Fragment>

	<p>Whatever the section renders.</p>

	<BottomNav
		slot="nav"
		value="inbox"
		label="Sections"
		tabs={[
			{ value: "inbox", label: "Inbox", icon: "folder", badge: 3 },
			{ value: "threads", label: "Threads", icon: "menu" },
			{ value: "you", label: "You", icon: "gear" },
		]}
	/>
</MobileShell>