Subscription Tracker
Every recurring charge in one list: monthly or yearly totals, next renewal dates, and items marked to cancel.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/subscription-tracker.json'use client'
import { useState } from 'react'
import './subscription-tracker.css'
export interface Sub { id: string; name: string; price: number; cycle: 'mo' | 'yr'; next: string }
export interface SubscriptionTrackerProps {
items: Sub[]
today?: string
per?: 'mo' | 'yr'
currency?: string
fill?: 'dark' | 'light'
className?: string
}
/** Recurring charges with normalised totals and savings. */
export function SubscriptionTracker({ items, today = '2026-11-04', per: p0 = 'mo', currency = 'USD', fill = 'dark', className }: SubscriptionTrackerProps) {
const [per, setPer] = useState(p0)
const [cut, setCut] = useState<string[]>([])
const m = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency })
const norm = (s: Sub) => (s.cycle === per ? s.price : per === 'mo' ? s.price / 12 : s.price * 12)
const total = items.filter((s) => !cut.includes(s.id)).reduce((a, s) => a + norm(s), 0)
const save = items.filter((s) => cut.includes(s.id)).reduce((a, s) => a + norm(s), 0)
const days = (d: string) => Math.round((new Date(d + 'T12:00:00').getTime() - new Date(today + 'T12:00:00').getTime()) / 86400000)
const sorted = [...items].sort((a, b) => a.next.localeCompare(b.next))
return (
<div className={['sd-sbt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-sbt__h">
<div><small>Subscriptions {per === 'mo' ? 'per month' : 'per year'}</small><b aria-live="polite">{m(total)}</b></div>
<div className="sd-sbt__seg" role="radiogroup" aria-label="Period">{(['mo', 'yr'] as const).map((x) => <button key={x} type="button" role="radio" aria-checked={per === x} onClick={() => setPer(x)}>{x === 'mo' ? 'Monthly' : 'Yearly'}</button>)}</div>
</div>
<p className="sd-sbt__save" aria-live="polite">{save ? <>Cancelling {cut.length} saves <b>{m(save)}</b> {per === 'mo' ? 'a month' : 'a year'}</> : `${items.length} active · tick any you’d cancel`}</p>
<ul className="sd-sbt__list">
{sorted.map((s) => {
const d = days(s.next)
return (
<li key={s.id} className="sd-sbt__r" data-x={cut.includes(s.id) || undefined}>
<span className="sd-sbt__ic" aria-hidden>{s.name[0]}</span>
<span className="sd-sbt__n">{s.name}{d >= 0 && d <= 7 && <em>{d === 0 ? 'Today' : `${d}d`}</em>}<small>Renews {new Date(s.next + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</small></span>
<span className="sd-sbt__p">{m(s.price)}<small>/{s.cycle === 'mo' ? 'month' : 'year'}</small></span>
<input type="checkbox" className="sd-sbt__x" aria-label={`Mark ${s.name} to cancel`} checked={cut.includes(s.id)} onChange={() => setCut((c) => (c.includes(s.id) ? c.filter((x) => x !== s.id) : [...c, s.id]))} />
</li>
)
})}
</ul>
</div>
)
}.sd-sbt { --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-sbt[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-sbt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-sbt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-sbt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-sbt { width: min(100%, 330px); padding: 16px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-sbt__h { display: flex; justify-content: space-between; align-items: flex-start; }
.sd-sbt__h small { display: block; font-size: 12px; color: var(--mute); }
.sd-sbt__h b { font-size: 30px; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.sd-sbt__seg { display: flex; padding: 2px; border-radius: 8px; background: var(--soft); }
.sd-sbt__seg button { padding: 4px 9px; border-radius: 6px; font-size: 12px; color: var(--mute) !important; }
.sd-sbt__seg button[aria-checked='true'] { background: var(--bg); color: var(--fg) !important; box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); }
.sd-sbt__save { margin: 6px 0 12px; font-size: 12.5px; min-height: 18px; }
.sd-sbt__list { list-style: none; margin: 0; padding: 0; }
.sd-sbt__r { display: grid; grid-template-columns: 34px 1fr auto 26px; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
.sd-sbt__r[data-x] .sd-sbt__n, .sd-sbt__r[data-x] .sd-sbt__p { opacity: 0.4; text-decoration: line-through; }
.sd-sbt__ic { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: var(--soft); font-size: 13px; font-weight: 800; }
.sd-sbt__n { min-width: 0; font-size: 14px; }
.sd-sbt__n small { display: block; font-size: 11.5px; color: var(--mute); }
.sd-sbt__n em { font-style: normal; margin-left: 6px; padding: 1px 6px; border-radius: 99px; background: var(--strong); color: var(--on); font-size: 10.5px; font-weight: 700; }
.sd-sbt__p { text-align: right; font-size: 13.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-sbt__p small { display: block; font-size: 11px; font-weight: 400; color: var(--mute); }
.sd-sbt__x { appearance: none; width: 22px; height: 22px; margin: 0; border-radius: 7px; box-shadow: inset 0 0 0 1.5px var(--line); cursor: pointer; display: grid; place-items: center; }
.sd-sbt__x::after { content: '×'; font-size: 14px; line-height: 1; color: var(--mute); }
.sd-sbt__x:checked { background: var(--strong); box-shadow: none; }
.sd-sbt__x:checked::after { color: var(--on); }
@media (prefers-reduced-motion: reduce) { .sd-sbt *, .sd-sbt *::before, .sd-sbt *::after { animation: none !important; transition: none !important; } }Overview
Rows show service, price, cycle and next charge. The header total flips between per-month and per-year, normalising mixed cycles.
Mark a subscription “to cancel” and the potential savings line updates. Renewals within a week are flagged.
When to use
- Personal finance apps, bank insights, admin spend views.
When not to use
- Actually cancelling: link out to each provider.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/subscription-tracker.json$
npx shadcn@latest add https://sundaeui.dev/r/subscription-tracker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/subscription-tracker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/subscription-tracker.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 subscription-tracker.tsx and subscription-tracker.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 { SubscriptionTracker } from '@/components/sundae/subscription-tracker'
<SubscriptionTracker />Props
| Prop | Type | Default |
|---|---|---|
items*Name, price, cycle, next date. | Sub[] | — |
todayISO date for “soon” flags. | string | — |
currencyCurrency. | string | 'USD' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Rows are a list; cancel toggles are checkboxes with the service name.
- Totals and savings are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-sbt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Subscription Tracker free?
- Yes. Subscription Tracker is free for personal and commercial projects, with full source on this page.
- How do I install Subscription Tracker?
- Run npx shadcn@latest add https://sundaeui.dev/r/subscription-tracker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Subscription Tracker support keyboard and screen readers?
- Rows are a list; cancel toggles are checkboxes with the service name. Totals and savings are announced.
- Does Subscription Tracker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Savings Goal
Data · Refined- Pro
Donut Breakdown
Data · Refined - Pro
Treemap
Data · Refined Net Worth
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined