Skip to content

Components

Every reusable UI primitive in frontend/components/ui/. These are theme-aware, presentation-only — no service calls, no business logic. Feature composites belong in higher-level folders (components/auth/, components/chat/, etc.).

This page is the catalog. For implementation details (the makeStyles(theme) factory pattern, how useTheme() flows in), see Frontend → Architecture.

Foundational

Button Primary (gold gradient), secondary (surface high), tertiary (ghost). Loading and disabled states. Always uses the md border radius.
Input Text input with floating label, validation, leading/trailing icon slots. Focus shifts background to surfaceContainerHighest and label to primary — no border swap.
Chip Passive status / label tag (uppercased, variant-coloured: success / warning / error / info / neutral). Uses sm radius. Distinct from SelectableChip (the pressable toggle).
MetaChip Compact icon + muted label metadata pill ({ icon, label, tint? }). The single source for the location / people / category / time meta lines on cards; replaces the inline <Ionicons> + <Text> repetition.
TagChipRow Wrapping row of tag pills with a built-in +N overflow ({ tags, getLabel?, max? }). Used wherever a truncated tag list is shown.
SelectableChip Interactive filter/toggle pill ({ label, selected?, onPress? }). Gold fill when selected, pill radius. Distinct from Chip: Chip is a passive status tag, SelectableChip is a pressable toggle. Renamed from the old planner/Chip.
Segmented Pill-track segmented control ({ options, value, onChange, labels }), generic over a string-literal union. The active tab sinks to the page background for an inset feel. Use this + a conditional pane render for an in-page 2/N-way toggle — never react-native-tab-view (its web pager lays panes side-by-side and clips).
IconChip The single icon-circle token ({ icon, size=36, iconSize=20, color?, bg? }): rounded-square, radius 12, glyph on primary at 10%. Every leading icon-circle (SettingsRow, InfoRow) routes through it; don't hand-roll another.
StampMedallion The passport-stamp emblem ({ icon, size=84, color? }): an outer solid ring around an inner dashed ring with a centred glyph, no fill. One source of truth for the welcome-screen slides, the error boundary, and every EmptyState, so the stamp reads identically wherever it appears. Colour is the accent only; never tilt or fill it.
Logo App mark, SVG, theme-aware. The only sanctioned brand mark in the codebase.

Page shell

The three nested width caps every screen composes. Details: Frontend → Routing.

ScreenContainer Outer screen frame (components/layout/). Desktop constrains to maxWidth 900 centered; mobile is a flex-1 passthrough. Always transparent so the app backdrop shows through.
Page Mobile collapsing title + scroll region. children is a render-prop giving the screen { onScroll, scrollEventThrottle, scrollRef } to spread onto its list. On desktop it renders content only; the sidebar + a global header (via useSetPageHeader) own title/actions. Never hand-roll a top bar.
ScreenBody The centered CRUD reading column ({ maxWidth=640, style }, paddingHorizontal:16). Default 640 for settings/detail; pass 420 for forms. Width tiers are 900 / 640 / 420 only.
KeyboardAwareScroll The one keyboard-aware scroll container for normal (non-sheet) screens (react-native-keyboard-controller): a focused input scrolls above the keyboard identically on iOS and Android. Drops into Page's render-prop seam (onScroll/scrollEventThrottle); web falls back to Animated.ScrollView. Sheets use TrueSheet's native handling instead; never nest a keyboard-controller view inside a TrueSheet.

Cards & rows

The shared card + row + metadata vocabulary. Every screen composes these; there are no bespoke event/plan/place/settings cards left.

AccentCard The base in-page card ({ children, leading?, onPress? }), radius 16, background via cardSurface(theme). Its boundary is a surface-ladder tonal step, never a stroke (the no-line rule). leading steps one tone brighter for lift. Both the event and plan canvases route their cards through it.
EventCard THE event component — variant="tile"|"hero"|"row" (browse tile / next-up hero / agenda row). Every surface-specific field is an optional slot. Search, horizon, and profile all use it; a plan renders as a tile fed plan data through the generic slots (the old plan-fold). No bespoke event or plan cards.
PlaceRow The one place row — search results, plan place-cards, location pickers. leading thumb and icon preset are mutually exclusive; surface frames the row: 'card' (default) is a standalone AccentCard; 'flat' keeps the padding but drops the surface/radius/hairline so stacked results read as one whitespace-separated list (used by LocationRow); 'none' drops the padding too, for embedding inside a card or band (used by PlaceCard).
SettingsRow Settings / nav / action row ({ icon?, title, subtitle?, onPress?, value?, trailing?, danger?, disabled? }). Leading IconChip. Chevron shows only when onPress && !trailing (value rows keep their chevron); toggle rows pass trailing={<Toggle/>}. Replaced the per-screen nav/preference rows.
DetailRow Read-only label ↔ value row ({ label, value, last? }): small-caps label left, value (string or node) right. Used for account/detail readouts.
UserRow THE person row ({ uri, name, handle?, size=44, trailing?, onPress?, href? }): avatar + name + @handle, name always 16/semibold. Friends, chat pickers, any people list. Delegates the avatar to UserAvatar.
AvatarStack Overlapping avatar row ({ people, size=24, max=4, overflowCount? }) with a surface-colored ring so overlaps read as separate circles, plus a trailing +N bubble. What EventCard's attendee foot uses.

Form fields & pickers

The components/ui/fields/ kit + the shared pickers. Compose these instead of raw TextInput/picker plumbing.

FieldShell The scaffold every field renders through ({ label?, required?, error?, children }): optional uppercase caption with a red * when required, optional error text below.
ReadOnlyField Read-only DetailRow + optional muted hint carrying one inline text-link ({ label, value, hint?, link? }).
SelectField Tap-to-open picker (icon + value + chevron) backed by the shared SelectionSheet ({ label?, icon, value, placeholder, options, onSelect, searchable? }). section options render as non-interactive dividers.
InlineEditField Always-editable field owning its draft ({ label?, value, onSave, validate?, multiline? }); reveals revert/confirm only once dirty and flashes a check on save. Adopts an external value change only when the draft is clean.
VisibilityPicker Audience/visibility control — a dropdown (pill trigger opens a Popover menu with a checkmark on the selected option), { value, options, onChange, width? }. Presentational: the caller supplies options. Used by capture, event, and plan. Replaced the old VisibilityChip. Note: the control is shared, but event (3 options) and plan/moment (4 options) still differ on the option set (a deferred product decision).

Layout & containers

ActionBar Sticky bottom action bar. Used inside modals and sheets when the primary action should always be reachable. Auto-respects safe-area insets on iOS.
InfoRow Icon-circle + overline label + value row ({ icon, label?, value, sub? }), with optional colour overrides for non-theme surfaces (e.g. a dark hero). The read-only metadata row on the event card and public event view.
DateBadge Calendar day-number + month badge ({ day, month, active? }); active gives the "today" primary fill. Used in event list rows.
GlassView Frosted/blurred surface. expo-blur on native, CSS backdrop-filter on web. Use sparingly — every glass surface is a moment.
NavBottomSheet Bottom sheet that hides the bottom nav while open (toggles isNavHidden via SheetContext). Pair with rich content that wants the full screen.
SnapBottomSheet Bottom sheet with snap detents (half-screen / full-screen). Pure UI gesture, no nav coupling.
Sheet Base bottom sheet (TrueSheet). Fixed header + floating footer chrome; the consumer provides the body via SheetView (auto-height content) or SheetScrollView (a fixed-height detent's scroll region) — both apply the standard 24px gutter and hasFooter clears the floating footer. height is auto or a detent array (e.g. [0.5, 1]); never auto when content can exceed the viewport (auto has no scroll region). footerBare renders the footer as-is; footerOptions.keyboardOffset tunes keyboard avoidance.
SwipeRow iOS-Mail-style left-swipe row: reveals one action (right) or several (rightActions, ~46px each); a single action can full-swipe to fire. Snaps open and holds until tapped or another row opens. Built on Reanimated + gesture-handler.
AnchoredPopover Popover anchored to a target element. Auto-flips position to stay in viewport.
Tooltip Hover tooltip on web, long-press tooltip on native. Same API; Platform.OS handles dispatch.

Feedback

EmptyState The one "you don't have any X yet" view: a StampMedallion, a tight headline, an optional one-line body, an optional primary CTA + secondary text link, and a compact mode for in-sheet / in-list use. tone is semantic, never decorative: default (primary) for content-empty, error (ember) for failures / not-found, love (#FF3B6B) for likes. Copy is playful and warm, matching the not-found / error pages.
Toast Notification rendered by ToastContext. Single queue. Variants: success, warning, error, info.
ConfirmationModal Yes/no destructive-action modal. Default-focuses the safe (cancel) button on open.
ConfirmDialog Centered confirm/destructive prompt (title + message + cancel/confirm). A modal, never a bottom sheet: confirms are usually raised from inside another sheet, where a second stacked sheet is confusing. Backs both useConfirm() (planner) and ConfirmSheet; destructive renders the confirm CTA in the danger tone.
ShareSheet Bottom sheet (mobile) / centered card (web) with circular brand-color targets (copy, native share, Messages, WhatsApp, X, Facebook, Reddit, LINE, in-app chat) and a copy-link bar.

Media & social

AssetImage Standard renderer for every network-loaded Tomoda asset (avatars, chat photos, moments, stamps, event covers). Wraps expo-image with memory-disk caching and a stable cacheKey derived from the URL path so cache entries survive base_url drift (DHCP, CDN swap). Pass cacheKey explicitly when the immutable storage key is available directly (e.g. chat metadata.images.keys).
UserAvatar Avatar with online indicator and initials fallback. Three sizes: sm, md, lg.
GroupAvatar Composite avatar for group chats — tessellates up to 4 participant avatars in a single circle.
GalleryCard Media gallery tile used in moments / events. Handles single image, multi-image carousel, and video preview.
StatCard Numeric stat tile (label + value + optional delta). Used on profile and admin dashboards.

Decorative & motion

FadeInView Mount animation wrapper. Default ease-out 240ms.
ScrollRevealView Reveals children as they enter the viewport on scroll. Used on landing / website surfaces, not in-product.
VoxelSlot Decorative animated tile (used on landing pages and brand-moment screens). The "physical display box" that grounds the 3D-toy aesthetic into the 2D UI.

Composition rules

  • Use the primitive as-is. If you need a variant, add it to the primitive — don't fork.
  • Stylesheet factories. Every component declares const makeStyles = (theme) => StyleSheet.create({...}) at the top. Components read theme via useTheme() once and memoize the resulting sheet — the factory runs on theme change, not on every render.
  • Compose, don't extend. Build feature components by composing primitives. A ChatMessageBubble is a View wrapping UserAvatar + body-lg text + (optional) Toast-style reaction tray. It doesn't subclass anything.

Buttons and i18n length

Translated labels can be 20–40% longer than English (German for compound nouns, Finnish for case suffixes, Polish for long roots). The button primitive uses a three-tier graceful degradation so non-English labels never overrun siblings and almost never truncate with .

Tier 1 — Standard row (default)

Wrap a row of buttons in <ButtonRow> from frontend/components/ui/Button.tsx. Every child is forced to flex: 1, so widths stay matched and side-by-side buttons render at the same font size. The label uses numberOfLines={1} + ellipsizeMode="tail" + flexShrink: 1. (We deliberately do not use adjustsFontSizeToFit — it shrinks each Text independently and produces mismatched font sizes across buttons in a row.)

Tier 2 — Compact size

For rows known to be tight even in English ("Accept" / "Decline" + "Block"; "Join" / "Chat" + "Manage"), pass size="compact" to the children. The primitive shrinks font (15→12) and horizontal padding (20→12), buying ~25% horizontal headroom before any stacking kicks in.

<ButtonRow>
  <Button size="compact" title={t('friend_detail.message')} ... />
  <Button size="compact" title={t('friend_detail.profile')} ... />
  <Button size="compact" title={t('common.share')} ... />
</ButtonRow>

Tier 3 — Auto-stack

If the natural label widths still wouldn't fit at the container's available width, <ButtonRow> flips to a vertical column (each child rendered full-width). Triggered only when text truly overflows — a small slack threshold prevents cosmetic stacking when labels are at the edge.

Set autoStack={false} if you've already designed for the worst case and want the row to stay horizontal regardless.

Authoring rule

Action-button text in feature components (actionBtnText, cancelBtnText, etc.) should follow the same pattern: add flexShrink: 1 to the style and pass numberOfLines={1} ellipsizeMode="tail" on the <Text>. Prefer <Button> + <ButtonRow> over hand-rolled <TouchableOpacity> for any new action surface.

Adding a primitive

  1. Drop the file in frontend/components/ui/.
  2. Follow the makeStyles(theme) pattern. No service calls inside, no business logic — just rendering.
  3. Add an entry to this page in the same PR, under the appropriate grouping above. Include: purpose, key variants, sizing if relevant.
  4. If the new primitive uses a previously-unused color token, also update Color.
  5. If it introduces a new motion duration or easing, also update Motion.

Storybook?

Not today. The catalog above + the source files are the canonical references. A live Storybook is a candidate future addition; for now, pull the component into a temporary playground screen if you need to iterate visually.