Savings Goal
A goal card with a progress ring; quick-add chips count the saved amount up and mark the goal done at 100%.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/savings-goal.json'use client'
import { useEffect, useRef, useState } from 'react'
import './savings-goal.css'
export interface SavingsGoalProps {
name?: string
goal: number
defaultSaved?: number
due?: Date
chips?: number[]
onAdd?: (cents: number) => void
fill?: 'dark' | 'light'
className?: string
}
const C = 2 * Math.PI * 70
function useTween(target: number) {
const [v, setV] = useState(target)
const from = useRef(target)
useEffect(() => {
const t0 = performance.now(), a = from.current
let raf = 0
const step = (t: number) => { const k = Math.min(1, (t - t0) / 600); const cur = a + (target - a) * (1 - (1 - k) ** 3); setV(cur); from.current = cur; if (k < 1) raf = requestAnimationFrame(step) }
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [target])
return v
}
/** Goal ring with quick-add chips. */
export function SavingsGoal({ name = 'Trip to Lisbon', goal, defaultSaved = 0, due, chips = [1000, 2500, 5000], onAdd, fill = 'dark', className }: SavingsGoalProps) {
const [saved, setSaved] = useState(defaultSaved)
const shown = useTween(saved)
const p = Math.min(1, saved / goal)
const done = saved >= goal
const usd = (c: number) => `$${Math.round(c / 100).toLocaleString('en-US')}`
const weeks = due ? Math.max(1, Math.ceil((due.getTime() - Date.now()) / (7 * 864e5))) : 0
return (
<div className={['sd-svg', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-svg__name">{name}</div>
<div className="sd-svg__ring" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p * 100)} aria-valuetext={`${usd(saved)} of ${usd(goal)}, ${Math.round(p * 100)} percent`}>
<svg viewBox="0 0 160 160" aria-hidden><circle className="bg" cx="80" cy="80" r="70" /><circle className="fg" cx="80" cy="80" r="70" strokeDasharray={C} strokeDashoffset={C * (1 - p)} /></svg>
<div className="sd-svg__c" aria-hidden>
{done ? <><svg viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7" /></svg><span>Goal reached</span></> : <><b>{usd(shown)}</b><span>of {usd(goal)}</span></>}
</div>
</div>
<div className="sd-svg__left" aria-live="polite">{done ? `You saved ${usd(saved)}` : `${usd(goal - saved)} to go${weeks ? ` · ${usd((goal - saved) / weeks)} a week` : ''}`}</div>
<div className="sd-svg__chips">
{chips.map((c) => <button key={c} type="button" className="sd-svg__chip" disabled={done} onClick={() => { setSaved((s) => s + c); onAdd?.(c) }}>+{usd(c)}</button>)}
</div>
</div>
)
}.sd-svg { --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-svg[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-svg button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-svg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-svg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-svg { width: min(100%, 300px); padding: 18px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); text-align: center; font-variant-numeric: tabular-nums; }
.sd-svg__name { font-size: 13px; color: var(--mute); }
.sd-svg__ring { position: relative; width: 160px; height: 160px; margin: 12px auto 10px; }
.sd-svg__ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.sd-svg__ring circle { fill: none; stroke-width: 10; }
.sd-svg__ring .bg { stroke: var(--soft); }
.sd-svg__ring .fg { stroke: var(--fg); stroke-linecap: round; transition: stroke-dashoffset 700ms cubic-bezier(0.3, 1, 0.4, 1); }
.sd-svg__c { position: absolute; inset: 0; display: grid; place-content: center; }
.sd-svg__c b { font-size: 26px; font-weight: 650; letter-spacing: -0.02em; }
.sd-svg__c span { font-size: 12px; color: var(--mute); }
.sd-svg__c svg { width: 34px; height: 34px; margin: 0 auto; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transform: none; animation: sd-svg-pop 420ms cubic-bezier(0.3, 1.7, 0.4, 1); }
@keyframes sd-svg-pop { from { transform: scale(0); } }
.sd-svg__left { font-size: 12.5px; color: var(--mute); min-height: 18px; }
.sd-svg__chips { display: flex; gap: 6px; justify-content: center; margin-top: 14px; }
.sd-svg__chip { height: 34px; padding: 0 13px; border-radius: 99px; font-size: 13px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); transition: transform 120ms, background-color 150ms; }
.sd-svg__chip:hover { background: var(--soft) !important; }
.sd-svg__chip:active { transform: scale(0.92); }
.sd-svg__chip:disabled { opacity: 0.35; cursor: default; }
@media (prefers-reduced-motion: reduce) { .sd-svg *, .sd-svg *::before, .sd-svg *::after { animation: none !important; transition: none !important; } }Overview
The ring fills in proportion to the goal and the amount counts up whenever money is added. Quick-add chips cover the common amounts.
Below the ring, a line says what is left and what that means per week before the target date.
When to use
- Personal finance, fundraising, team targets.
When not to use
- Open-ended totals with no target.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/savings-goal.json$
npx shadcn@latest add https://sundaeui.dev/r/savings-goal.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/savings-goal.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/savings-goal.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 savings-goal.tsx and savings-goal.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 { SavingsGoal } from '@/components/sundae/savings-goal'
<SavingsGoal />Props
| Prop | Type | Default |
|---|---|---|
goal*Target in cents. | number | — |
saved / defaultSavedSaved so far in cents. | number | — |
dueTarget date. | Date | — |
onAddCalled on add. | (cents: number) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The ring is a progressbar with the amount and percent as aria-valuetext.
- Reaching the goal is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-svg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Savings Goal free?
- Yes. Savings Goal is free for personal and commercial projects, with full source on this page.
- How do I install Savings Goal?
- Run npx shadcn@latest add https://sundaeui.dev/r/savings-goal.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Savings Goal support keyboard and screen readers?
- The ring is a progressbar with the amount and percent as aria-valuetext. Reaching the goal is announced.
- Does Savings Goal respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Subscription Tracker
Data · RefinedTransaction List
Data · Refined- Pro
Weekly Bars
Data · Refined Watchlist
Data · Refined- Pro
Treemap
Data · Refined OKR Tracker
Data · Refined