Spend Limit
Set a monthly spend alert by dragging a marker along this month’s usage bar, with a forecast of when you’ll hit it.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/spend-limit.json'use client'
import { useRef, useState } from 'react'
import './spend-limit.css'
export interface SpendLimitProps {
spent: number
dayOfMonth: number
daysInMonth: number
defaultLimit?: number
defaultPause?: boolean
onSave?: (s: { limit: number; pause: boolean }) => void
fill?: 'dark' | 'light'
className?: string
}
/** Usage bar with a draggable spend limit and forecast. */
export function SpendLimit({ spent, dayOfMonth, daysInMonth, defaultLimit, defaultPause = false, onSave, fill = 'dark', className }: SpendLimitProps) {
const forecast = (spent / dayOfMonth) * daysInMonth
const scale = Math.ceil((Math.max(forecast, spent) * 1.4) / 100) * 100
const [limit, setLimit] = useState(defaultLimit ?? Math.round(forecast * 0.9 / 10) * 10)
const [pause, setPause] = useState(defaultPause)
const [saved, setSaved] = useState(false)
const track = useRef<HTMLDivElement>(null)
const drag = useRef(false)
const pct = (v: number) => `${Math.min(100, (v / scale) * 100)}%`
const set = (v: number) => { setLimit(Math.max(10, Math.min(scale, Math.round(v / 10) * 10))); setSaved(false) }
const at = (x: number) => { const r = track.current!.getBoundingClientRect(); set(((x - r.left) / r.width) * scale) }
const hitDay = limit <= spent ? dayOfMonth : Math.ceil(limit / (spent / dayOfMonth))
const usd = (n: number) => `$${Math.round(n).toLocaleString()}`
return (
<div className={['sd-spl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-spl__h"><b>{usd(spent)}</b><span>spent · forecast {usd(forecast)}</span></div>
<div ref={track} className="sd-spl__track" onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = true; at(e.clientX) }} onPointerMove={(e) => drag.current && at(e.clientX)} onPointerUp={() => { drag.current = false }}>
<span className="sd-spl__rail" />
<span className="sd-spl__fc" style={{ left: pct(spent), width: `calc(${pct(forecast)} - ${pct(spent)})` }} />
<span className="sd-spl__spent" style={{ width: pct(spent) }} />
<span className="sd-spl__mk" role="slider" tabIndex={0} aria-label="Monthly limit" aria-valuemin={10} aria-valuemax={scale} aria-valuenow={limit} aria-valuetext={usd(limit)} style={{ left: pct(limit) }}
onKeyDown={(e) => { const d = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 10 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -10 : 0; if (d) { e.preventDefault(); set(limit + d) } }}><span>{usd(limit)}</span></span>
</div>
<p className="sd-spl__msg" aria-live="polite">
{limit <= spent ? <><b>You’re already past this limit.</b> {pause ? 'Usage would pause immediately.' : 'You’d get an alert right away.'}</>
: hitDay <= daysInMonth ? <>At this pace you’ll reach {usd(limit)} around <b>day {hitDay}</b>. {pause ? 'Usage pauses then.' : 'We’ll email you then.'}</>
: <>Your forecast stays under {usd(limit)} this month. <b>No alert expected.</b></>}
</p>
<div className="sd-spl__row">
<div className="sd-spl__seg" role="radiogroup" aria-label="At the limit">
<button type="button" role="radio" aria-checked={!pause} onClick={() => { setPause(false); setSaved(false) }}>Alert me</button>
<button type="button" role="radio" aria-checked={pause} onClick={() => { setPause(true); setSaved(false) }}>Pause usage</button>
</div>
<button type="button" className="sd-spl__save" onClick={() => { setSaved(true); onSave?.({ limit, pause }) }}>{saved ? 'Saved' : 'Save'}</button>
</div>
</div>
)
}.sd-spl { --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-spl[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-spl button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-spl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-spl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-spl { width: min(100%, 330px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-spl__h { display: flex; justify-content: space-between; align-items: baseline; }
.sd-spl__h b { font-size: 26px; font-weight: 650; letter-spacing: -0.02em; }
.sd-spl__h span { font-size: 12.5px; color: var(--mute); }
.sd-spl__track { position: relative; height: 34px; margin: 18px 6px 6px; touch-action: none; cursor: pointer; }
.sd-spl__rail { position: absolute; left: 0; right: 0; top: 13px; height: 8px; border-radius: 99px; background: var(--soft); }
.sd-spl__spent { position: absolute; left: 0; top: 13px; height: 8px; border-radius: 99px; background: var(--fg); }
.sd-spl__fc { position: absolute; top: 13px; height: 8px; border-radius: 0 99px 99px 0; box-shadow: inset 0 0 0 1.5px var(--fg); opacity: 0.45; border: 0; }
.sd-spl__mk { position: absolute; top: 0; width: 2px; height: 34px; margin-left: -1px; background: var(--fg); outline: 0; }
.sd-spl__mk::after { content: ''; position: absolute; top: -6px; left: -7px; width: 16px; height: 16px; border-radius: 5px; background: var(--strong); box-shadow: 0 0 0 3px var(--bg); }
.sd-spl__mk:focus-visible::after { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--fg); }
.sd-spl__mk span { position: absolute; top: 38px; left: 50%; transform: translateX(-50%); font-size: 11.5px; font-weight: 650; white-space: nowrap; }
.sd-spl__msg { margin-top: 26px; font-size: 13px; line-height: 1.45; color: var(--mute); min-height: 38px; }
.sd-spl__msg b { color: var(--fg); }
.sd-spl__row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.sd-spl__seg { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; padding: 3px; border-radius: 10px; background: var(--soft); }
.sd-spl__seg button { height: 30px; border-radius: 7px; font-size: 12.5px; color: var(--mute); }
.sd-spl__seg button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg); font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-spl__save { height: 36px; padding: 0 14px; border-radius: 10px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 13px; }
@media (prefers-reduced-motion: reduce) { .sd-spl *, .sd-spl *::before, .sd-spl *::after { animation: none !important; transition: none !important; } }Overview
The bar shows spend so far and a dashed forecast to month end. Drag the limit marker (or type a value) and the card says whether, and on what day, the forecast crosses it.
A toggle chooses between just alerting and pausing usage at the limit.
When to use
- Usage-based billing: APIs, cloud, AI credits, ads.
When not to use
- Flat subscriptions.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/spend-limit.json$
npx shadcn@latest add https://sundaeui.dev/r/spend-limit.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/spend-limit.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/spend-limit.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 spend-limit.tsx and spend-limit.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 { SpendLimit } from '@/components/sundae/spend-limit'
<SpendLimit />Props
| Prop | Type | Default |
|---|---|---|
spent*Spend so far this month ($). | number | — |
dayOfMonth / daysInMonth*For the forecast. | number | — |
defaultLimitInitial limit ($). | number | — |
onSaveCalled on save. | (s: { limit: number; pause: boolean }) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The marker is a slider with currency aria-valuetext; the forecast sentence is a live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-spl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Adjust limit by $10
FAQ
- Is Spend Limit free?
- Yes. Spend Limit is free for personal and commercial projects, with full source on this page.
- How do I install Spend Limit?
- Run npx shadcn@latest add https://sundaeui.dev/r/spend-limit.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Spend Limit support keyboard and screen readers?
- The marker is a slider with currency aria-valuetext; the forecast sentence is a live region. Reduced motion removes the movement and keeps every state change.
- Does Spend Limit respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Invoice Builder
Forms · RefinedExpense Entry
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined