Spacing & Elevation¶
How we use space (between elements) and depth (between layers). Both compose into the "tonal layering" approach that defines Tomoda's UI.
Spacing scale¶
A 4-pixel base with named steps. Stick to the scale — half-steps and arbitrary pixels make the system feel sloppy.
When to use which¶
| Step | Use |
|---|---|
xs (4) |
Icon-to-text gap, inline label-value pairing |
sm (8) |
Tight grouping — within a card, between list metadata |
md (12) |
List item vertical separation |
lg (16) |
Section padding inside a card |
xl (24) |
Card internal padding, top-of-section margin |
2xl (32) |
Between major content blocks |
3xl (48) |
Hero / display surfaces, generous landing spacing |
Avoid lg for card internal padding when content is rich — xl or 2xl lets the content breathe and reinforces the "curator's frame" feel.
Border radius¶
Three corner sizes. Use one consistently per surface tier.
| Token | Value | Use |
|---|---|---|
sm |
6px | Chips, pills, tight controls |
md |
10px | Buttons, inputs, small cards |
lg |
16px | Cards, modals, sheets |
xl |
24px | Large hero surfaces, full-bleed cards |
Don't mix md and lg for elements at the same hierarchy level — the inconsistency reads as accidental.
Icon & logo sizing¶
Two named scales set the size of glyphs and the brand mark, in px. They live in frontend/constants/icons.ts as ICON_SIZES and LOGO_SIZES, mirroring how SPACING and RADIUS work. Size from a token, not a hardcoded number, so glyphs stay consistent across screens.
ICON_SIZES covers in-app iconography:
| Token | Value | Use |
|---|---|---|
xs |
14px | Inline affordance next to text (chevron in a label row) |
sm |
16px | Metadata glyph, dense list trailing icon |
md |
18px | Default icon in a row or button |
lg |
20px | Primary action icon, tab bar |
xl |
22px | Emphasised row / control glyph |
xxl |
24px | Prominent header or empty-state icon |
xxxl |
28px | Feature glyph, oversized touch target |
display |
40px | Hero / illustration-scale glyph |
LOGO_SIZES sizes the brand T mark for the surfaces it sits on:
| Token | Value | Use |
|---|---|---|
sm |
28px | Inline header wordmark, compact nav |
md |
32px | Sidebar, landing overlay wordmark |
lg |
52px | Welcome / onboarding brand lockup |
xl |
64px | Auth header brand lockup |
Every in-app icon renders through the shared VectorIcon (a bare Ionicons name or a family:name spec) or through LogoT, sized from these tokens. LogoT's size prop takes a LogoSize token (default md) or a raw number for the rare off-scale case. Reach for a raw number only when a value falls outside the scale; otherwise the token keeps the mark in step with the rest of the system.
Surface ladder (depth without shadow)¶
The core insight: depth comes from stepping the surface ladder, not from shadows. Place a higher-tier surface on top of a lower-tier one and the eye sees real depth. Values are the source of truth in constants/Themes.ts; the per-theme card rung is expressed once in constants/surfaces.ts.
The in-page ladder¶
Light and dark do not use the same token per layer. In dark, surface (#17171A) recedes below the page background, so it is not a card token there. The card/row layer is per theme: white in light, surfaceContainerHigh in dark, resolved by cardSurface(theme).
| Visual layer | Dark token / hex | Light token / hex |
|---|---|---|
| Page background | background #1C1C1E |
background #F7F5F0 |
| Section well (wraps cards) | surfaceContainerLow #1E1E22 |
surfaceContainerLow #F0EDE6 |
Card / row (cardSurface) |
surfaceContainerHigh #28282D |
surface #FFFFFF |
| Chip / input on a card | surfaceContainerHighest #32323A |
surfaceContainerHighest #D8D3C8 |
| Nested on highest | primary @ 1A |
primary @ 1A |
Composition rules¶
- A standalone card or row on the page uses the card rung (
cardSurface(theme)), neversurfaceContainerLow. Low is near-invisible against the dark page background on its own. surfaceContainerLowis a well that contains cards, not a standalone panel. It reads as "carved" only when cards sit inside it.- A chip or input sitting on a card steps to
surfaceContainerHighestso it separates from the card tone (in dark the card is alreadysurfaceContainerHigh).
surface is the overlay tier
surface is the floating / overlay token (sheets, popovers, modals), not an in-page card token. For an in-page card or row, reach for cardSurface(theme).
Shadows (used sparingly)¶
Reserved for elements that genuinely float. The surface ladder handles all in-context depth.
| Token | Value | Use |
|---|---|---|
shadow-modal |
0px 24px 48px rgba(0, 0, 0, 0.35) |
Modals, fullscreen sheets |
shadow-floating |
0px 12px 24px rgba(0, 0, 0, 0.25) |
Dropdowns, popovers |
shadow-elevated |
0px 4px 12px rgba(0, 0, 0, 0.20) |
Sticky bottom bars, FABs |
Shadow rules¶
- Never pure black. Shadow opacity tops out at 0.4 to keep the warm-charcoal feel.
- Diffused, not crisp. Large blur radius, no spread. Shadows should feel like ambient occlusion, not hard cutouts.
- No top shadow. Light always falls from above; shadow always falls below. Top-shadows look like floating UFOs.
- No shadow on a
backgroundsurface. If the underlying surface is the app root, you don't need a shadow to imply depth — use the surface ladder.
Glassmorphism¶
A specific exception to the no-shadow rule: floating UI that should feel frosted rather than opaque.
- Surface:
surfaceContainerLowat 70% opacity - Backdrop filter:
blur(24px) - Wrapped by the
GlassViewcomponent which handles native (expo-blur) and web (backdrop-filterCSS) paths.
Use for floating navigation pills, contextual overlays on top of imagery, and the chat composer when it sits over content. Don't use as a default — every glass surface is a moment.
Ghost borders¶
When a border is absolutely required (focus state, error state, accessibility outline), use the border token, never solid:
- Dark:
rgba(255, 255, 255, 0.15) - Light:
rgba(0, 0, 0, 0.10)
This is the only sanctioned use of a border in product UI. Solid #000 / #fff borders are forbidden — see the no-line rule.
Layout density¶
Two density modes appear in production:
- Comfortable (default) —
xlpadding on cards,lg/xlbetween list items. Used everywhere user-content lives (chat, event detail, profile). - Dense —
lgpadding,mdbetween items. Used in admin tables, settings lists, anywhere the screen is configuration-heavy.
Don't introduce a third density. Comfortable defaults serve almost everything; dense covers the rest.