Expense Entry
Log an expense fast: big amount field, category chips, date shortcuts, split with a friend, and an attached receipt.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/expense-entry.json'use client'
import { useState } from 'react'
import './expense-entry.css'
export interface Expense { amount: number; note: string; category: string; date: string; split: boolean; receipt: boolean }
export interface ExpenseEntryProps {
categories: string[]
currency?: string
today?: string
split?: boolean
onSave?: (e: Expense) => void
fill?: 'dark' | 'light'
className?: string
}
/** Quick expense logging form. */
export function ExpenseEntry({ categories, currency = 'USD', today = '2026-11-04', split: s0 = false, onSave, fill = 'dark', className }: ExpenseEntryProps) {
const [amt, setAmt] = useState('')
const [note, setNote] = useState('')
const [cat, setCat] = useState(categories[0])
const [date, setDate] = useState(today)
const [split, setSplit] = useState(s0)
const [receipt, setReceipt] = useState(false)
const [ok, setOk] = useState('')
const yest = new Date(Date.parse(today + 'T12:00:00Z') - 864e5).toISOString().slice(0, 10)
const n = Number(amt) || 0
const sym = (0).toLocaleString('en-US', { style: 'currency', currency }).replace(/[\d.,\s]/g, '')
const m = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency })
const save = () => {
onSave?.({ amount: n, note, category: cat, date, split, receipt })
setOk(`Saved ${m(n)} · ${cat}${split ? ` · owed ${m(n / 2)}` : ''}`); setAmt(''); setNote(''); setReceipt(false)
}
return (
<div className={['sd-exp', className].filter(Boolean).join(' ')} data-fill={fill}>
<label className="sd-exp__amt"><span aria-hidden>{sym}</span><input inputMode="decimal" aria-label="Amount" placeholder="0" value={amt} onChange={(e) => { const v = e.target.value.replace(/[^\d.]/g, ''); if (/^\d{0,6}(\.\d{0,2})?$/.test(v)) setAmt(v) }} /></label>
<input type="text" aria-label="What for" placeholder="What was it for?" value={note} onChange={(e) => setNote(e.target.value)} maxLength={40} />
<div className="sd-exp__cats" role="radiogroup" aria-label="Category">{categories.map((c) => <button key={c} type="button" role="radio" aria-checked={cat === c} onClick={() => setCat(c)}>{c}</button>)}</div>
<div className="sd-exp__dt"><button type="button" aria-pressed={date === today} onClick={() => setDate(today)}>Today</button><button type="button" aria-pressed={date === yest} onClick={() => setDate(yest)}>Yesterday</button><input type="date" aria-label="Date" value={date} onChange={(e) => setDate(e.target.value)} /></div>
<div className="sd-exp__sp"><span>Split with Sam<small>{split ? (n ? `Sam owes you ${m(n / 2)}` : 'Half each') : 'Just you'}</small></span><button type="button" role="switch" aria-checked={split} aria-label="Split with Sam" className="sd-exp__sw" onClick={() => setSplit((s) => !s)} /></div>
<div className="sd-exp__f"><button type="button" className="sd-exp__rc" data-on={receipt || undefined} aria-pressed={receipt} onClick={() => setReceipt((r) => !r)}>{receipt ? 'Receipt ✓' : '+ Receipt'}</button><button type="button" className="sd-exp__go" disabled={!n} onClick={save}>Save {n ? m(n) : ''}</button></div>
<p className="sd-exp__ok" role="status">{ok}</p>
</div>
)
}.sd-exp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-exp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-exp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-exp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-exp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-exp { width: min(100%, 320px); padding: 16px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 12px; }
.sd-exp__amt { display: flex; align-items: baseline; justify-content: center; gap: 2px; padding: 6px 0; }
.sd-exp__amt span { font-size: 26px; color: var(--mute); font-weight: 600; }
.sd-exp__amt input { width: 7ch; border: 0; background: none; color: var(--fg); font: 700 44px/1 ui-sans-serif, system-ui; letter-spacing: -0.03em; text-align: center; outline: 0; font-variant-numeric: tabular-nums; }
.sd-exp__amt input::placeholder { color: var(--line); }
.sd-exp input[type='text'], .sd-exp input[type='date'] { height: 38px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; outline: 0; width: 100%; box-sizing: border-box; color-scheme: dark; }
.sd-exp[data-fill='light'] input[type='date'] { color-scheme: light; }
.sd-exp input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-exp__cats { display: flex; flex-wrap: wrap; gap: 5px; }
.sd-exp__cats button, .sd-exp__dt button { padding: 6px 11px; border-radius: 99px; font-size: 12.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-exp__cats button[aria-checked='true'], .sd-exp__dt button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-exp__dt { display: flex; gap: 5px; align-items: center; }
.sd-exp__dt input { flex: 1; height: 32px !important; }
.sd-exp__sp { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; }
.sd-exp__sp > span { flex: 1; }
.sd-exp__sp small { display: block; color: var(--mute); font-size: 11.5px; }
.sd-exp__sw { position: relative; width: 38px; height: 22px; border-radius: 99px; background: var(--line) !important; transition: background-color 160ms; flex: none; }
.sd-exp__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--fg); transition: transform 180ms cubic-bezier(0.3, 1.3, 0.5, 1); }
.sd-exp__sw[aria-checked='true'] { background: var(--fg) !important; }
.sd-exp__sw[aria-checked='true']::after { transform: translateX(16px); background: var(--bg); }
.sd-exp__f { display: flex; gap: 6px; }
.sd-exp__rc { padding: 0 12px; height: 42px; border-radius: 12px; font-size: 12.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-exp__rc[data-on] { box-shadow: inset 0 0 0 1.5px var(--fg); font-weight: 600; }
.sd-exp__go { flex: 1; height: 42px; border-radius: 12px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-exp__go:disabled { opacity: 0.4; }
.sd-exp__ok { text-align: center; font-size: 12.5px; color: var(--mute); min-height: 16px; margin: -4px 0 0; }
@media (prefers-reduced-motion: reduce) { .sd-exp *, .sd-exp *::before, .sd-exp *::after { animation: none !important; transition: none !important; } }Overview
The amount takes focus first and formats as you type. Categories are a chip row; Today and Yesterday cover most dates, with a date field for the rest.
Split halves it (or a custom share) and shows what you are owed. A receipt chip stands in for an attached photo.
When to use
- Budget apps, shared trip ledgers, reimbursement flows.
When not to use
- Formal expense reports with approvals.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/expense-entry.json$
npx shadcn@latest add https://sundaeui.dev/r/expense-entry.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/expense-entry.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/expense-entry.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy expense-entry.tsx and expense-entry.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ExpenseEntry } from '@/components/sundae/expense-entry'
<ExpenseEntry />Props
| Prop | Type | Default |
|---|---|---|
categories*Category chips. | string[] | — |
currencyISO currency. | string | 'USD' |
onSaveCalled on save. | (e: Expense) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Categories are a radiogroup; split is a switch.
- Save confirms with a status message and clears the form.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-exp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Expense Entry free?
- Yes. Expense Entry is free for personal and commercial projects, with full source on this page.
- How do I install Expense Entry?
- Run npx shadcn@latest add https://sundaeui.dev/r/expense-entry.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Expense Entry support keyboard and screen readers?
- Categories are a radiogroup; split is a switch. Save confirms with a status message and clears the form.
- Does Expense Entry respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedRating Scale
Forms · Refined