Outlay — a warm, calm, local-first expense tracker. Log a spend in 5 seconds, see exactly what's left this month. No accounts, no cloud, 100% offline.
Outlay is a warm, calm, local-first expense tracker for iOS and Android, built with Expo (React Native). Log spending in under 5 seconds and instantly see what's left of your monthly budget — no bank links, no sign-ups, no network calls. All data stays on your device. SQLite on-device, no analytics, no accounts, offline forever Tech stack: Expo Router · TypeScript (strict) · NativeWind v4 · expo-sqlite · Zustand · Reanimated · Lucide icons
Beginner & Intermediate Developers looking to level up their React Native skills and build production-ready, full-stack mobile applications.
Copy the master prompt or setup docs below and use them in your editor or AI assistant.
Save this as CLAUDE.md in your project root so Claude Code follows the project's setup and conventions.
This file is the binding contract for building Outlay. It wins over any other instruction, including the master prompt, on any conflict. Read it fully before writing a single line of code. Copy it to the project root as the first commit.
expo-sqlite is the single source of truth. No accounts, no network calls, no analytics. Architecture must keep a clean repository layer (src/db/queries/*) so a sync engine can be added later without touching screens.| Decision | Borrowed from | Mobbin rating |
|---|---|---|
| Warm cream canvas, indigo primary, kind copy voice | YNAB | 5.0 |
| White card system, semantic income/expense colors, time-range chips, confetti success moment | Monarch | 4.67 |
| Full-width bottom-anchored primary CTA, "use before you sign up" (we go further: no signup at all) | Copilot Money | 5.0 |
| Budget progress ring as the single hero element on Home | Rocket Money | 4.33 |
| Anti-patterns to avoid (both rated 4.33 — lowest in the research set): dense multi-widget dashboards, red gradient surfaces, gamified badge clutter (Rocket Money); ALL-CAPS shouty marketing copy, upsell carousels, >2 onboarding interstitials (Cleo) | — | — |
Create src/theme/tokens.ts with exactly this object. No inline magic numbers anywhere in the app — every color, size, radius, duration must come from these tokens (via Tailwind classes or this object).
export const palette = {
light: {
bg: '#FAF6EE',
surface: '#FFFFFF',
surfaceSunken: '#F2EDE2',
text: '#1F1D1A',
textMuted: '#6E6A61',
textFaint: '#B4B0A5',
border: '#E8E2D5',
primary: '#5B5BD6',
primaryPressed: '#4A4AC4',
primarySoft: '#EEEDFB',
onPrimary: '#FFFFFF',
income: '#0F6E56',
incomeSoft: '#E1F5EE',
expense: '#993C1D',
expenseSoft: '#FAECE7',
warning: '#BA7517',
warningSoft: '#FAEEDA',
danger: '#A32D2D',
dangerSoft: '#FCEBEB',
ringTrack: '#EEEDFB',
overlay: 'rgba(31, 29, 26, 0.45)',
},
dark: {
bg: '#171511',
surface: '#211F1A',
surfaceSunken: '#1B1916',
text: '#F2EFE6',
textMuted: '#A39E92',
textFaint: '#6E6A61',
border: '#35322B',
primary: '#8585E8',
primaryPressed: '#9B9BF0',
primarySoft: '#2B2A45',
onPrimary: '#171511',
income: '#4ECBA0',
incomeSoft: '#11332A',
expense: '#F0997B',
expenseSoft: '#3A241B',
warning: '#FAC775',
warningSoft: '#33270F',
danger: '#F09595',
dangerSoft: '#3A1A1A',
ringTrack: '#2B2A45',
overlay: 'rgba(0, 0, 0, 0.6)',
},
} as const;
export const typography = {
// Sora = display/headings/money. Inter = body/labels.
display: { fontFamily: 'Sora_600SemiBold', fontSize: 32, lineHeight: 38 }, // big money figures
h1: { fontFamily: 'Sora_600SemiBold', fontSize: 28, lineHeight: 34 },
h2: { fontFamily: 'Sora_500Medium', fontSize: 22, lineHeight: 28 },
h3: { fontFamily: 'Sora_500Medium', fontSize: 17, lineHeight: 24 },
body: { fontFamily: 'Inter_400Regular', fontSize: 15, lineHeight: 22 },
bodyMedium:{ fontFamily: 'Inter_500Medium', fontSize: 15, lineHeight: 22 },
label: { fontFamily: 'Inter_500Medium', fontSize: 13, lineHeight: 18 },
caption: { fontFamily: 'Inter_400Regular', fontSize: 13, lineHeight: 18 },
micro: { fontFamily: 'Inter_500Medium', fontSize: 11, lineHeight: 14 },
moneyRow: { fontFamily: 'Sora_500Medium', fontSize: 15, lineHeight: 22 }, // amounts in list rows
} as const;
export const spacing = { 1: 4, 2: 8, 3: 12, 4: 16, 5: 20, 6: 24, 7: 28, 8: 32, 10: 40, 12: 48 } as const; // 4pt scale
export const radius = { sm: 8, md: 12, lg: 16, xl: 24, pill: 999 } as const;
export const shadows = {
card: { shadowColor: '#1F1D1A', shadowOpacity: 0.08, shadowRadius: 8, shadowOffset: { width: 0, height: 2 }, elevation: 2 },
sheet: { shadowColor: '#1F1D1A', shadowOpacity: 0.16, shadowRadius: 24, shadowOffset: { width: 0, height: 8 }, elevation: 8 },
} as const; // only these two levels exist — nothing else gets a shadow
export const motion = {
fast: 150, base: 250, slow: 400, // ms, with easeOutCubic
spring: { damping: 18, stiffness: 220 }, // reanimated spring for presses/sheets
ringMount: 800, // home ring fills 0 → value on mount, easeOutCubic
} as const;
Mirror palette and the pixel scales into tailwind.config.js (theme.extend.colors with light-bg, dark-bg, etc. or via CSS variables + darkMode: 'class' strategy NativeWind supports). Tailwind config and tokens.ts must never disagree; tokens.ts is canonical.
Packages: @expo-google-fonts/sora, @expo-google-fonts/inter, expo-font, expo-splash-screen.
Load in the root layout exactly like this and keep the splash screen up until ready:
import { useFonts, Sora_500Medium, Sora_600SemiBold } from '@expo-google-fonts/sora';
import { Inter_400Regular, Inter_500Medium } from '@expo-google-fonts/inter';
const [fontsLoaded] = useFonts({
Sora_500Medium, Sora_600SemiBold, Inter_400Regular, Inter_500Medium,
});
// SplashScreen.preventAutoHideAsync() at module scope; hide when fontsLoaded && db ready.
Only these 4 font weights exist in the app. Never request others.
| Component | Spec |
|---|---|
| Button / primary | Height 52, radius 16, bg primary, text onPrimary 15px Inter_500Medium. Pressed: bg primaryPressed + scale 0.98 (spring). Disabled: 40% opacity. Full-width, anchored above safe-area bottom inset + 16 on forms (Copilot pattern). |
| Button / secondary | Height 52, radius 16, bg surface, 1px border border, text text. |
| Button / destructive | Height 52, radius 16, bg dangerSoft, text danger. Always behind a confirm dialog. |
| Button / small | Height 36, radius 12, padding-x 16, 13px Inter_500Medium. |
| Card | bg surface, radius 16, padding 16, shadow card, 1px border border (border only in light mode; dark mode relies on surface contrast, border border at 50% opacity). |
| Input | Height 52, radius 12, bg surface, 1px border border, focus border primary (2px), text 15px Inter_400Regular, placeholder textFaint. Error state: border danger + 13px caption below in danger. |
| Chip (category/filter) | Height 32, radius pill, padding-x 12, 13px Inter_500Medium. Unselected: bg surface, border border, text textMuted. Selected: bg primarySoft, text light #3C3489 / dark #C9C9F5, no border. Press = haptic selection tick. |
| List row (transaction) | Height 64, padding-x 16. Left: 36px circle, bg category-color-soft, lucide icon 18px category color. Middle: title 15px text + caption 13px textMuted. Right: amount in moneyRow type — expenses plain text with "−", income income with "+". Pressed: bg surfaceSunken. Separated by 1px border inset 16 left. |
| Progress ring (Home hero) | 168px diameter, 12px stroke, round caps. Track ringTrack, fill primary; fill switches to warning at ≥85% of budget and danger/expense at >100%. Center: remaining amount in display type + caption "left of {budget}". Animates 0 → value in 800ms on mount. |
| Category bar (Insights) | 8px height, radius pill, track ringTrack, fill = category color. |
| Tab bar | Height 64 + bottom safe area, bg surface, top 1px border border. 4 tabs (lucide icons 24px): active primary, inactive textFaint, 11px micro labels. Center FAB: 56px circle, bg primary, white plus icon 24px, floats 16px above bar, shadow sheet, press = scale 0.9 spring + medium haptic. |
| Modal sheet | Slides from bottom, top radius 24, bg bg, grabber 36×4 radius pill border centered 8px from top, backdrop overlay. Full-height for Add expense; auto-height for pickers. |
| Empty state | Centered: lucide icon 48px textFaint, h3 title, body textMuted max 2 lines, optional small button. Copy is warm, never guilt-inducing (YNAB voice). |
| Section header | 13px label type, textMuted, uppercase tracking +0.5, margin top 24 bottom 8. |
| Toast/confirm | Use native Alert for destructive confirms; custom toast: card style, bottom-floating, auto-dismiss 2.5s. |
"strict": true), no any, no @ts-ignore.src/hooks/ which call src/db/queries/*. Screens never import the db client directly.amountMinor), formatted with Intl.NumberFormat via src/lib/currency.ts. Never float math on money.dayjs only, ISO 8601 strings in DB, month keys as YYYY-MM. All date logic in src/lib/dates.ts.lucide-react-native only.react-native-reanimated only. Haptics via expo-haptics wrapped in src/lib/haptics.ts.expo-crypto randomUUID.// src/db/types.ts — exported, canonical
export type TxType = 'expense' | 'income';
export type Account = 'cash' | 'card' | 'mobile';
export interface Category {
id: string; // uuid
name: string;
icon: string; // lucide icon name, e.g. 'utensils'
color: string; // hex from the fixed category palette below
type: TxType;
sortOrder: number;
isArchived: 0 | 1;
}
export interface Transaction {
id: string;
type: TxType;
amountMinor: number; // integer, e.g. 1250 = 12.50
categoryId: string;
account: Account;
note: string; // '' allowed
occurredAt: string; // ISO datetime
createdAt: string;
updatedAt: string;
}
export interface Budget {
id: string;
categoryId: string | null; // null = overall monthly budget
amountMinor: number;
// applies to every month until changed (single active row per category)
}
export interface Setting { key: string; value: string; }
// keys used: 'currencyCode' (default 'USD'), 'theme' ('system'|'light'|'dark'),
// 'reminderTime' ('' or 'HH:mm'), 'onboardingDone' ('0'|'1')
SQL tables mirror these 1:1 (categories, transactions, budgets, settings), created in src/db/migrations.ts with a PRAGMA user_version migration pattern. Index: transactions(occurredAt), transactions(categoryId).
Seed categories (inserted on first run): Food #D85A30/utensils, Groceries #BA7517/shopping-cart, Transport #185FA5/bus, Fun #993556/party-popper, Bills #534AB7/receipt, Health #0F6E56/heart-pulse, Shopping #72243E/shopping-bag, Other #5F5E5A/circle-dashed — all expense; Salary #0F6E56/banknote, Other income #1D9E75/coins — income. Soft backgrounds for icons = color at 14% opacity.
outlay/
├── CLAUDE.md
├── app.json
├── tailwind.config.js
├── global.css
├── tsconfig.json
├── app/
│ ├── _layout.tsx // fonts, splash, ThemeProvider, DB init, Stack
│ ├── onboarding.tsx // 3-pane pager + currency picker
│ ├── (tabs)/
│ │ ├── _layout.tsx // custom tab bar with center FAB
│ │ ├── index.tsx // Home
│ │ ├── transactions.tsx // Transactions list
│ │ ├── insights.tsx // Insights
│ │ └── settings.tsx // Settings
│ ├── add-transaction.tsx // modal (presentation: 'modal'), also edit via ?id=
│ ├── transaction/[id].tsx // Transaction detail (push)
│ ├── category/[id].tsx // Category detail (push)
│ ├── budgets.tsx // Edit budgets (push)
│ └── data.tsx // Export & erase (push)
├── src/
│ ├── components/
│ │ ├── ui/ // Button.tsx, Card.tsx, Chip.tsx, Input.tsx, ListRow.tsx,
│ │ │ // ProgressRing.tsx, AmountText.tsx, EmptyState.tsx,
│ │ │ // SheetGrabber.tsx, SectionHeader.tsx, Toast.tsx, Confetti.tsx
│ │ ├── home/ // BudgetRingCard.tsx, CategoryChipsRow.tsx, RecentList.tsx
│ │ ├── transactions/ // TxRow.tsx, MonthSwitcher.tsx, SearchBar.tsx
│ │ ├── insights/ // SpendBarChart.tsx, CategoryBreakdown.tsx, RangeChips.tsx
│ │ └── add/ // AmountKeypad.tsx, CategoryGrid.tsx, AccountPicker.tsx, DateRow.tsx
│ ├── db/ // client.ts, migrations.ts, seed.ts, types.ts
│ │ └── queries/ // transactions.ts, categories.ts, budgets.ts, settings.ts, stats.ts
│ ├── hooks/ // useTransactions.ts, useMonthSummary.ts, useBudgets.ts,
│ │ // useCategories.ts, useSettings.ts, useInsights.ts
│ ├── lib/ // currency.ts, dates.ts, haptics.ts, exportData.ts, notifications.ts
│ ├── state/ // appStore.ts (zustand: selectedMonth, themeOverride)
│ └── theme/ // tokens.ts, useTheme.ts (resolves system + override → palette)
└── playground/ // TEMPORARY primitive gallery, deleted before final commit
/onboarding) — shown only when onboardingDone = '0'Top-to-bottom: skippable 3-pane horizontal pager (dots, 8px, active primary); each pane = lucide icon 64px in 96px primarySoft circle, h1 title, body textMuted 2 lines max, all centered.
onboardingDone='1', replace to /(tabs). Empty state copy everywhere assumes zero data. No account creation, no paywall, no permission asks here (anti-Cleo)./(tabs)/index)Top-to-bottom:
/budgets. Under ring: 3 stat columns — Spent / Budget / Daily avg (caption + moneyRow each)./category/[id]./transaction/[id]. Footer link-row "See all" → transactions tab.sparkles, "Fresh start" / "Tap the + button and log your first expense. It takes five seconds."
Interactions: ring animates on focus; pull-to-refresh re-queries (even though local — feels alive)./add-transaction, modal)Layout top-to-bottom: grabber; header row — h3 "Add expense" (or "Edit expense"/"…income"), right close icon; segmented control Expense | Income (pill, 36px, selected bg primarySoft); centered display amount preview building from keypad ("$0.00" placeholder textFaint); CategoryGrid: 4-column grid of 64px cells (icon circle 44px + 11px micro label), selected = 2px primary border on circle; DateRow: chips Today / Yesterday / "Pick date…" (opens native date picker); AccountPicker: 3 chips Cash / Card / Mobile; note Input ("Add a note…", optional, 1 line); AmountKeypad: 3×4 (1-9, ".", 0, backspace), 15px Sora_500Medium keys, height 56 each, haptic selection per tap; bottom primary button "Save expense" — disabled until amount > 0 and category chosen.
On save: write to DB, dismiss, success haptic, Confetti burst (1.2s, ~24 particles in primary/income/warning colors) over Home + ring re-animates (Monarch moment, kept subtle). Editing (?id=): prefill, button "Save changes", header gains trash icon → confirm → delete.
/(tabs)/transactions)MonthSwitcher (‹ June 2026 ›, h3, chevrons 44px hit areas); summary strip card: In / Out / Net (3 columns, moneyRow, income green, out plain, net colored by sign); SearchBar (Input 44px, icon search, filters note+category live); list grouped by day — SectionHeader "Today", "Yesterday", else "Mon, Jun 9" with day total right-aligned caption; ListRows per §5. Tap row → detail; long-press → action sheet (Edit / Delete with confirm). Empty month: icon calendar-x, "Nothing logged in June" / "Switch months or add something with the + button."
/(tabs)/insights)RangeChips: This month / 3M / 6M / Year (Monarch pattern, pill chips); SpendBarChart (Card): bars of total spend per month in range (or per week when "This month"), 12px bars radius 4, current period primary others ringTrack, y-axis 3 gridline captions, animated height on mount 400ms; CategoryBreakdown (Card): rows — icon circle, name, category bar (§5) sized to share of spend, amount + % caption; tap → /category/[id]; budgets summary card: per-category budgets with mini progress bars (color flips per ring rules), button "Edit budgets" → /budgets. Empty: icon chart-pie, "No spending to chart yet."
/category/[id], push)Header: back, icon circle 44px, h2 name, right pencil icon → rename sheet (Input + color row + icon row from fixed sets). This-month card: spent amount display, vs-budget bar if budget exists, daily-avg caption. Then month-grouped transaction list filtered to category (reuse components). Footer (archived-able): small destructive text-button "Archive category" (kept out of seed 'Other').
/transaction/[id], push)Centered: category icon circle 56px, display signed amount (− plain text color for expense, + income), caption merchant/note headline (note first line or category name), caption date "Jun 11, 9:42 AM". Card of rows: Category (chip) / Date / Account / Note — each 44px row, label left textMuted, value right. Bottom row: secondary button "Edit" (opens /add-transaction?id=) + destructive "Delete" (confirm alert: "Delete this expense?" / "It'll vanish from your totals. This can't be undone." / Cancel · Delete).
/(tabs)/settings)Card sections of 52px rows (label + right value/chevron/switch):
expo-notifications)./budgets)./data).expo-constants); "Made with Outlay" caption footer./budgets, push)Row 1 card: Overall monthly budget — Input (currency-masked) 52px. Then one row per active expense category: icon, name, right-aligned amount Input (empty = no budget). Autosaves on blur with debounce 400ms + toast "Saved". Caption footer: "Budgets repeat every month until you change them."
/data, push)Card 1: "Export your data" body caption + secondary buttons "Export CSV" and "Export JSON" → expo-file-system write + expo-sharing share sheet. CSV columns: date, type, category, amount, account, note. Card 2 (danger zone): body "Erase everything" caption "Deletes all transactions, budgets and settings from this device. There is no cloud copy." + destructive button "Erase all data" → confirm alert with second confirm ("Type-free double confirm: Alert → second Alert") → wipe tables, reseed categories, reset to onboarding.
Do
react-native-safe-area-context).Don't
Date math outside dates.ts, no hardcoded hex outside tokens.ts/tailwind.config.js, no any.