Skip to content
omaris

Build

Building a screen

Shell, rhythm, surfaces, the four states, and the phone — in the order you build them.

A screen is built from the outside in: the frame, then the rhythm of the content, then the surfaces things sit on, then the states the data can be in, then the phone. Do it in that order and each step has something to hang on.

1. The frame

Dashboard frame

The layout most omaris apps end up with. Only the content column scrolls — the sidebar stays put and the app bar finds its own scroller, which is what makes sticky work without a second scrollbar.

Overview

<script lang="ts">	import {		AppShell,		AppShellSidebar,		AppShellMain,		AppShellContent,		NavigationDrawer,		NavigationDrawerItem,		TopAppBar	} from 'omaris';​	let selected = $state('Overview');</script>​<div class="h-96 w-full overflow-hidden rounded-shape-lg border border-border">	<AppShell class="h-full">		<AppShellSidebar breakpoint="sm">			<NavigationDrawer bordered label="Sections">				<NavigationDrawerItem					label="Overview"					selected={selected === 'Overview'}					onclick={() => (selected = 'Overview')}				/>				<NavigationDrawerItem					label="Orders"					selected={selected === 'Orders'}					onclick={() => (selected = 'Orders')}				/>				<NavigationDrawerItem					label="Customers"					selected={selected === 'Customers'}					onclick={() => (selected = 'Customers')}				/>			</NavigationDrawer>		</AppShellSidebar>​		<AppShellMain>			<TopAppBar title={selected} sticky />			<AppShellContent width="lg">				<div class="flex flex-col gap-3">					<!-- Enough rows to have something to scroll. -->					{#each { length: 12 }, i (i)}						<div class="h-12 rounded-shape-md bg-surface-container"></div>					{/each}				</div>			</AppShellContent>		</AppShellMain>	</AppShell></div>

AppShell gives you the layout most dashboards end up with, and the one thing that is hard to add later: only the content column scrolls. The sidebar stays put, the app bar sticks to its own scroller, and there is never a second scrollbar. AppShellContent supplies the page gutter and the vertical rhythm between its children — sections are direct children of it and own no padding of their own.

width is the reading measure. full is the default and right for a wide table; xl or lg for a page of cards and prose.

Content width

width sets the reading measure and centres it. full is right for a table, md for prose.

width="sm" — a column narrow enough to read comfortably, centred in whatever space the shell has.

<script lang="ts">	import { AppShell, AppShellMain, AppShellContent } from 'omaris';</script>​<div class="h-64 w-full overflow-hidden rounded-shape-lg border border-border">	<AppShell>		<AppShellMain>			<AppShellContent width="sm">				<div class="rounded-shape-md bg-primary-container p-4">					<p class="text-body-md text-primary-container-foreground">						width="sm" — a column narrow enough to read comfortably, centred in whatever space the						shell has.					</p>				</div>			</AppShellContent>		</AppShellMain>	</AppShell></div>

2. The rhythm

Spacing is Tailwind's numeric scale, which the theme rescales with the density setting — so gap-4 is already right, and p-[16px] is a bug. These are the layouts a dashboard is made of. Use them rather than inventing a grid per page, and the pages agree with each other for free.

LayoutClasses
Stat rowgrid gap-4 sm:grid-cols-2 xl:grid-cols-4
Chart row, one widegrid gap-4 lg:grid-cols-3 and lg:col-span-2 on the wide card
Two upgrid gap-4 lg:grid-cols-2
Card listgrid gap-4 sm:grid-cols-2 xl:grid-cols-3
Inside a cardflex flex-col gap-3
Page heading with controlsflex flex-wrap items-end justify-between gap-4
A cluster of buttonsflex items-center gap-2

Type has a rhythm too, and it is the Text variant: headline-md for the page title, title-lg for a section, title-md for a card, body-md for everything you read, label-md for a column header. A metric is display-sm with tabular. The variant carries size, weight, leading and tracking together, so there is nothing to add after it.

Scale

Five roles, three sizes each. The variant carries size, line height, letter spacing and weight together — if you find yourself adding font-semibold after it, the role is wrong, not the weight.

display-sm

The quick brown fox

headline-md

The quick brown fox

title-lg

The quick brown fox

body-md

The quick brown fox

label-md The quick brown fox
<script lang="ts">	import { Text } from 'omaris';</script>​<div class="flex w-full flex-col gap-4">	<div class="flex flex-col gap-1">		<Text variant="label-sm" tone="muted" font="mono">display-sm</Text>		<Text variant="display-sm">The quick brown fox</Text>	</div>	<div class="flex flex-col gap-1">		<Text variant="label-sm" tone="muted" font="mono">headline-md</Text>		<Text variant="headline-md">The quick brown fox</Text>	</div>	<div class="flex flex-col gap-1">		<Text variant="label-sm" tone="muted" font="mono">title-lg</Text>		<Text variant="title-lg">The quick brown fox</Text>	</div>	<div class="flex flex-col gap-1">		<Text variant="label-sm" tone="muted" font="mono">body-md</Text>		<Text variant="body-md">The quick brown fox</Text>	</div>	<div class="flex flex-col gap-1">		<Text variant="label-sm" tone="muted" font="mono">label-md</Text>		<Text variant="label-md">The quick brown fox</Text>	</div></div>

3. The surfaces

Depth is tiers, not borders. The page is bg-background; a panel is surface-container-low; a card is surface-container; something raised inside it, or a hover, is surface-container-high. Up one tier per level of nesting, and stop at three. A Card does this for you — outlined when it sits on a plain page, elevated in a grid of cards, filled for a well.

Variants

outlined is the default. elevated lifts — a grid of cards on a page wants it; filled sits in a tinted well, for a card inside another surface.

elevated

The same card, three surfaces.

filled

The same card, three surfaces.

outlined

The same card, three surfaces.
<script lang="ts">	import { Card, CardContent, CardHeader } from 'omaris';</script>​<Card variant="elevated" class="w-56 max-w-full">	<CardHeader heading="elevated" />	<CardContent>The same card, three surfaces.</CardContent></Card><Card variant="filled" class="w-56 max-w-full">	<CardHeader heading="filled" />	<CardContent>The same card, three surfaces.</CardContent></Card><Card variant="outlined" class="w-56 max-w-full">	<CardHeader heading="outlined" />	<CardContent>The same card, three surfaces.</CardContent></Card>

A Divider is for when the grouping is still not clear after the surfaces and the spacing. It is not the default way to separate two things.

4. The four states

Every list, every table, every chart has four states, and the design is only finished when all four are on screen. The empty one is the one people forget, and it is the first thing a new user sees.

StateShowComponent
LoadingThe shape of what is comingSkeleton, or loading on the Table
EmptyWhy it is empty, and the one action that changes thatEmpty
ErrorWhat went wrong and how to try againAlert tone="destructive" with an action
ReadyThe dataThe component

States

Loading draws skeleton rows at the real row height, so nothing jumps.

NameCityTotal
NameCityTotal
No orders yet. They will show up here the moment one lands.
<script lang="ts">	import { Table } from 'omaris';​	const COLUMNS = [		{ key: 'name', header: 'Name' },		{ key: 'city', header: 'City' },		{ key: 'total', header: 'Total', numeric: true }	];</script>​<div class="flex w-full flex-col gap-6">	<Table columns={COLUMNS} rows={[]} loading loadingRows={3} label="Loading" />	<Table		columns={COLUMNS}		rows={[]}		emptyText="No orders yet. They will show up here the moment one lands."		label="Empty"	/></div>

A skeleton is the same shape as the content it stands in for — a table's rows, a card's title and two lines — so nothing jumps when the data lands. Progress bars are for a known amount; a skeleton is for "soon".

5. The phone

The same screen, not a second one. Three things carry a dashboard onto a phone:

  • The drawer becomes modal. NavigationDrawer responsive is a sidebar above 900px and a modal below it, from one drawer.
  • The bottom gets a bar. AppShellSidebar hides below md, so give phones a Navigation Bar in AppShellFooter — three to five destinations, the ones that matter.
  • Sheets, not dialogs, for tasks. A Sheet is a bottom sheet on a phone with a handle to throw it away by; a Dialog is for a decision.

Basic

Three to five destinations, on a phone. More than five wants a drawer.

<script lang="ts">	import { NavigationBar, NavigationBarItem } from 'omaris';​	let tab = $state('home');</script>​{#snippet house()}	...{/snippet}​{#snippet bag()}	...{/snippet}​{#snippet person()}	...{/snippet}​<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border">	<NavigationBar label="Sections">		<NavigationBarItem label="Home" selected={tab === 'home'} onclick={() => (tab = 'home')}>			{#snippet icon()}{@render house()}{/snippet}		</NavigationBarItem>		<NavigationBarItem			label="Orders"			badge={3}			selected={tab === 'orders'}			onclick={() => (tab = 'orders')}		>			{#snippet icon()}{@render bag()}{/snippet}		</NavigationBarItem>		<NavigationBarItem			label="Profile"			selected={tab === 'profile'}			onclick={() => (tab = 'profile')}		>			{#snippet icon()}{@render person()}{/snippet}		</NavigationBarItem>	</NavigationBar></div>

Every component is built on logical properties (ms-, pe-, start-), so a right-to-left language mirrors the whole screen without anything in your code changing. Write your own layout the same way and it stays true.

Whole screens to copy

Twelve complete apps live at /patterns, each on its own URL and built only from what this library exports — a dashboard, an inbox, a till, a board, a shop, sign-in, a wizard, settings, a fleet map, a phone app, a clinic schedule and an AI assistant. Open the one closest to what you are building and take the layout. Anything in them is something you can have.

The frame, the rhythm and the states make a screen that works. Making it look designed is what makes it one you would sign.