Skip to content

Component Patterns

Conventions for building consistent UI components.

Layout Components

PageShell

Standard page wrapper with consistent layout.

<PageShell maxWidth="md" scrollable>
  <PageHeader title={t("markers")} />
  <MarkerList markers={markers} />
</PageShell>

Props: - maxWidth — Container max width (xs, sm, md, lg, xl) - scrollable — Enable vertical scrolling

EmptyState

Placeholder when no content exists.

<EmptyState
  icon={<MarkerIcon />}
  title={t("noMarkers")}
  description={t("noMarkersDescription")}
  action={
    <Button onClick={openCreate}>
      {t("createFirst")}
    </Button>
  }
/>

Form Components

Specialized Fields

Component Location Purpose
ColorSelector components/forms/ Color selection from palette
IconSelector components/forms/ Icon selection grid
DatePickerField components/forms/ Date selection
SelectField components/forms/ Dropdown select
SearchSelectField components/forms/ Searchable autocomplete
SearchField components/forms/ Search input with clear
PasswordField components/forms/ Password input with toggle
RichTextField components/forms/ Rich text editor
MonthYearSelector features/budget/components/ Month/year navigation
AccountSelectField features/budget/components/ Budget account select
CategorySelectField features/budget/components/ Category select

Naming convention: Form field wrappers use *Field suffix; selectors use *Selector suffix.

ColorSelector

<ColorSelector
  value={color}
  onChange={setColor}
  colors={CATEGORY_COLORS}
/>

IconSelector

<IconSelector
  value={icon}
  onChange={setIcon}
  icons={ICONS}
/>

Button Patterns

Use custom Button component, not raw MUI:

import Button from "@/components/ui/Button";

<Button variant="contained" color="primary">
  {t("save")}
</Button>

<Button variant="outlined" color="error" loading={isDeleting}>
  {t("delete")}
</Button>

Features: - Loading state with spinner - Disabled during loading - Consistent styling

List Patterns

Sortable Images

SortableImageGrid for drag-to-reorder image galleries with @dnd-kit/sortable.

Infinite Scroll

Use the InfiniteScrollTrigger component (in components/feedback/) with TanStack Query's useInfiniteQuery for paginated lists — render it at the end of the list to load the next page when it scrolls into view.

Loading States

Skeletons

All skeleton components are consolidated in components/feedback/skeletons/:

Component Purpose
FormSkeleton Form placeholder
ListItemSkeleton Single list item placeholder
ListSkeleton Full list placeholder with configurable item count
import ListSkeleton from "@/components/feedback/skeletons/ListSkeleton";

{isLoading ? <ListSkeleton count={5} /> : <ItemList items={items} />}

Suspense

<Suspense fallback={<PageSkeleton />}>
  <LazyMarkerPage />
</Suspense>

Component Organization

components/
├── ui/                  # Button, InfoTooltip, EmailStatusBadge, TruncatedText, UserAvatar,
│                        # DonutChart, PaginatedList, PaginatedTable, OverlayIconButton
├── feedback/            # EmptyState, InfiniteScrollTrigger, PullToRefresh*, PwaUpdate*
│   ├── dialogs/         # ConfirmDeleteDialog, UnsavedChangesDialog
│   ├── skeletons/       # FormSkeleton, ListItemSkeleton, ListSkeleton
│   └── toast/           # Toast system
├── forms/               # FLAT: FormModal, FormErrorAnnouncer, *Field wrappers,
│                        # ColorSelector, IconSelector, RichText*
├── images/              # ImageCard, SortableImageCard/Grid, ShimmerImage, ImageCropper,
│                        # ImageUpload, FullscreenImageViewer
├── layout/              # PageShell, PageTransition, ErrorBoundary, ThemeWrapper,
│                        # HubButton, ModalStackHandler
└── routing/             # Route guards (ProtectedRoute, GuestRoute)

Domain-specific components live in their feature folders (e.g., features/budget/components/AmountDisplay, features/settings/components/SectionCard, features/account/components/Captcha).