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.
| Layout | Classes |
|---|---|
| Stat row | grid gap-4 sm:grid-cols-2 xl:grid-cols-4 |
| Chart row, one wide | grid gap-4 lg:grid-cols-3 and lg:col-span-2 on the wide card |
| Two up | grid gap-4 lg:grid-cols-2 |
| Card list | grid gap-4 sm:grid-cols-2 xl:grid-cols-3 |
| Inside a card | flex flex-col gap-3 |
| Page heading with controls | flex flex-wrap items-end justify-between gap-4 |
| A cluster of buttons | flex 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.
The quick brown fox
The quick brown fox
The quick brown fox
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
filled
outlined
<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.
| State | Show | Component |
|---|---|---|
| Loading | The shape of what is coming | Skeleton, or loading on the Table |
| Empty | Why it is empty, and the one action that changes that | Empty |
| Error | What went wrong and how to try again | Alert tone="destructive" with an action |
| Ready | The data | The component |
States
Loading draws skeleton rows at the real row height, so nothing jumps.
<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 responsiveis a sidebar above 900px and a modal below it, from one drawer. - The bottom gets a bar.
AppShellSidebarhides belowmd, so give phones a Navigation Bar inAppShellFooter— 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.
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.