Overlays
Dialog is the web default and does not exist in the native kit — mobile uses sheets, and how a screen is presented lives in a route table rather than in the screen.
Both kits can put something over the page. They disagree about what that should be, and the disagreement is deliberate rather than an omission.
Web: Dialog is the default
@statshub/ui-web wraps Base UI's dialog. Reach for it when the user has to
make one decision or fill one short form, and the page behind it is context they
should not lose.
import { Dialog, DialogContent, DialogTitle } from "@statshub/ui-web/components/dialog";The rest of the vocabulary, and when each one beats a dialog:
| Component | Use it when |
|---|---|
dialog | A focused decision or a short form. The default. |
sheet | A panel anchored to an edge — the mobile filter panel is one, side="left" |
bottom-sheet, top-sheet | Edge-anchored and draggable, on Silk |
long-sheet | The content is taller than the viewport and scrolls |
sheet-with-keyboard | It contains an input the on-screen keyboard would cover |
drawer | Navigation, not a task |
popover | A small control anchored to the thing that opened it |
command | A searchable list — palettes, pickers |
hover-card, tooltip | Explanation on hover. Never for anything a touch user needs |
Two of those are traps. tooltip and hover-card do not exist on touch, so
anything only reachable by hover is unreachable on a phone — hybrid-tooltip is
the wrapper for when you need one anyway. And a drawer full of form fields is
a dialog that opened from the wrong edge.
Mobile: there is no Dialog, on purpose
@statshub/ui-native ships no dialog, no modal and no alert component. That is
not a gap to fill. iOS and Android both express "something over the page" as a
sheet, and a centred box over a dimmed backdrop reads as a web page wearing a
native costume.
Sheets live in sheet/:
| File | What it is |
|---|---|
sheet.tsx | The primitive |
sheet-surface.tsx | The surface every sheet is drawn on |
persistent-sheet.tsx | One that stays up while the board behind it is used |
filter-sheet-screen.tsx | The shell a filters sheet is — see UI |
tray.tsx | The screener's older stack-based tray |
surface-style.ts | The store; the app owns reading and writing it |
Presentation is a route table, not a prop
The native kit declares the vocabulary and the app decides which route gets which:
type RoutePresentation = "card" | "modal" | "fullScreenModal" | "formSheet";A screen does not know how it is presented. lib/core/route-presentation.ts
holds the mapping, because how a screen appears is something you change for
navigation reasons, and grouping screens by role rather than by presentation is
what lets you change it in one place.
The mobile architecture page covers the two names
every route has.
This is one of the three seams the kit leaves to the app
The kit owns the RoutePresentation vocabulary because sheets and headers read
it. Which route gets which presentation stays in the app. A kit file reaching
for @/lib/... means the component belongs in the app instead — see
ui-native.
Choosing, on either platform
Ask what happens to the page underneath.
- It stays relevant and the user comes back to it — dialog on web, sheet on mobile.
- It is replaced for the duration — a route, not an overlay.
fullScreenModalon mobile; a page on web. - It is a menu of destinations —
draweron web, a pushed route on mobile. - It explains one control —
popover, and only if a touch user can reach it.
Icons and imagery
Every filter option and every row of data carries a crest, a headshot or a symbol — and what to do for the ones that cannot.
Where state lives
On web the server fetches server state and passes it as props; the URL owns the rest, through nuqs. On mobile React Query owns server state and a zustand store owns the rest, because there is no URL to put it in.