Watchlist
A stock watchlist with sparklines: sort by name, price or change, toggle % and $ change, and remove with undo.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/watchlist.json'use client'
import { useEffect, useState } from 'react'
import './watchlist.css'
export interface Quote { sym: string; name: string; price: number; prev: number; spark: number[] }
type Key = 'sym' | 'price' | 'chg'
export interface WatchlistProps {
items: Quote[]
currency?: string
change?: 'pct' | 'abs'
sparks?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Sortable watchlist with sparklines and undoable remove. */
export function Watchlist({ items: i0, currency = 'USD', change = 'pct', sparks = true, fill = 'dark', className }: WatchlistProps) {
const [items, setItems] = useState(i0)
const [sort, setSort] = useState<{ k: Key; dir: 1 | -1 } | null>(null)
const [mode, setMode] = useState(change)
const [edit, setEdit] = useState(false)
const [undo, setUndo] = useState<{ q: Quote; i: number } | null>(null)
useEffect(() => { if (!undo) return; const t = setTimeout(() => setUndo(null), 4000); return () => clearTimeout(t) }, [undo])
const chg = (q: Quote) => (q.price - q.prev) / q.prev
const val = (q: Quote, k: Key) => (k === 'sym' ? q.sym : k === 'price' ? q.price : chg(q))
const rows = sort ? [...items].sort((a, b) => { const x = val(a, sort.k), y = val(b, sort.k); return (x < y ? -1 : x > y ? 1 : 0) * sort.dir }) : items
const by = (k: Key) => setSort(sort?.k === k ? (sort.dir === 1 ? { k, dir: -1 } : null) : { k, dir: k === 'sym' ? 1 : -1 })
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency, maximumFractionDigits: 2 })
const path = (s: number[]) => { const lo = Math.min(...s), hi = Math.max(...s); return s.map((v, k) => `${k ? 'L' : 'M'}${(k / (s.length - 1)) * 64} ${24 - ((v - lo) / (hi - lo || 1)) * 22}`).join(' ') }
const hd = (k: Key, l: string) => <button type="button" aria-sort={sort?.k === k ? (sort.dir === 1 ? 'ascending' : 'descending') : undefined} onClick={() => by(k)}>{l}</button>
return (
<div className={['sd-wls', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-wls__top"><b>Watchlist</b><button type="button" className="sd-wls__edit" aria-pressed={edit} onClick={() => setEdit(!edit)}>{edit ? 'Done' : 'Edit'}</button></div>
<div className="sd-wls__hd">{hd('sym', 'Symbol')}{sparks ? <span /> : <span />}{hd('price', 'Price')}{hd('chg', 'Change')}</div>
<ul className="sd-wls__list">
{rows.map((q) => {
const c = chg(q), up = c >= 0
return (
<li key={q.sym} className="sd-wls__r" style={{ gridTemplateColumns: `${edit ? '22px ' : ''}1fr 64px 76px 70px` }}>
{edit && <button type="button" className="sd-wls__rm" aria-label={`Remove ${q.sym}`} onClick={() => { setUndo({ q, i: items.indexOf(q) }); setItems(items.filter((x) => x !== q)) }}>−</button>}
<span className="sd-wls__sym">{q.sym}<small>{q.name}</small></span>
{sparks ? <svg className="sd-wls__sp" viewBox="0 0 64 26" data-down={!up || undefined} aria-hidden><path d={path(q.spark)} vectorEffect="non-scaling-stroke" /></svg> : <span />}
<span className="sd-wls__px">{money(q.price)}</span>
<button type="button" className="sd-wls__ch" data-up={up || undefined} data-dn={!up || undefined} aria-label={`${up ? 'Up' : 'Down'} ${mode === 'pct' ? `${Math.abs(c * 100).toFixed(2)}%` : money(Math.abs(q.price - q.prev))}. Show ${mode === 'pct' ? 'amount' : 'percent'}`}
onClick={() => setMode(mode === 'pct' ? 'abs' : 'pct')}>
{up ? '+' : '−'}{mode === 'pct' ? `${Math.abs(c * 100).toFixed(2)}%` : Math.abs(q.price - q.prev).toFixed(2)}
</button>
</li>
)
})}
</ul>
{undo && <div className="sd-wls__undo" role="status"><span>Removed {undo.q.sym}</span><button type="button" onClick={() => { const n = items.slice(); n.splice(undo.i, 0, undo.q); setItems(n); setUndo(null) }}>Undo</button></div>}
</div>
)
}.sd-wls { --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-wls[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-wls :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-wls :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-wls .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-wls { position: relative; width: min(100%, 340px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-wls__top { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 6px; }
.sd-wls__top b { font-size: 15px; }
.sd-wls__edit { padding: 4px 10px; border-radius: 99px; font-size: 12.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-wls__hd { display: grid; grid-template-columns: 1fr 64px 76px 70px; gap: 8px; padding: 6px 16px; font-size: 11px; color: var(--mute); }
.sd-wls__hd button { color: inherit !important; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; text-align: left; }
.sd-wls__hd button:nth-child(2), .sd-wls__hd button:nth-child(3) { text-align: right; }
.sd-wls__hd button[aria-sort='ascending']::after { content: ' ↑'; }
.sd-wls__hd button[aria-sort='descending']::after { content: ' ↓'; }
.sd-wls__hd button[aria-sort] { color: var(--fg) !important; }
.sd-wls__list { list-style: none; margin: 0; padding: 0 6px 8px; }
.sd-wls__r { display: grid; grid-template-columns: auto 1fr 64px 76px 70px; gap: 8px; align-items: center; padding: 9px 10px; border-radius: 12px; animation: sd-wls-in 220ms ease-out; }
.sd-wls__r:hover { background: var(--soft); }
.sd-wls__rm { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; font-size: 14px; line-height: 1; animation: sd-wls-pop 200ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-wls__sym { min-width: 0; font-size: 14px; font-weight: 700; }
.sd-wls__sym small { display: block; font-size: 11.5px; font-weight: 400; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-wls__sp { width: 64px; height: 26px; }
.sd-wls__sp path { fill: none; stroke: var(--fg); stroke-width: 1.5; stroke-linejoin: round; }
.sd-wls__sp[data-down] path { stroke: var(--mute); stroke-dasharray: 2.5 2; }
.sd-wls__px { text-align: right; font-size: 13.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-wls__ch { justify-self: end; min-width: 64px; padding: 4px 6px; border-radius: 7px; font-size: 12.5px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.sd-wls__ch[data-up] { background: var(--strong) !important; color: var(--on) !important; }
.sd-wls__ch[data-dn] { box-shadow: inset 0 0 0 1.5px var(--line); }
.sd-wls__undo { position: absolute; left: 10px; right: 10px; bottom: 10px; display: flex; justify-content: space-between; padding: 9px 12px; border-radius: 12px; background: var(--strong); color: var(--on); font-size: 13px; animation: sd-wls-in 200ms ease-out; }
.sd-wls__undo button { font-weight: 600; text-decoration: underline; }
@keyframes sd-wls-in { from { opacity: 0; transform: translateY(4px); } }
@keyframes sd-wls-pop { from { transform: scale(0); } }
@media (prefers-reduced-motion: reduce) { .sd-wls *, .sd-wls *::before, .sd-wls *::after { animation: none !important; transition: none !important; } }Overview
Each row has a ticker, a mini sparkline, the price and the day’s change. Tap the change pill to flip between percent and absolute for every row.
Column headers sort; tapping again reverses. Edit mode shows remove buttons, with undo for the last removal.
When to use
- Brokerage and crypto apps, finance dashboards.
When not to use
- Full portfolio views with positions and P&L.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/watchlist.json$
npx shadcn@latest add https://sundaeui.dev/r/watchlist.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/watchlist.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/watchlist.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 watchlist.tsx and watchlist.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 { Watchlist } from '@/components/sundae/watchlist'
<Watchlist />Props
| Prop | Type | Default |
|---|---|---|
items*Rows. | { sym: string; name: string; price: number; prev: number; spark: number[] }[] | — |
currencyCurrency. | string | 'USD' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Sort headers are buttons with aria-sort on the active one.
- Change is announced as “up 1.2%” or “down 0.4%”, never colour alone.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-wls; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Watchlist free?
- Yes. Watchlist is free for personal and commercial projects, with full source on this page.
- How do I install Watchlist?
- Run npx shadcn@latest add https://sundaeui.dev/r/watchlist.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Watchlist support keyboard and screen readers?
- Sort headers are buttons with aria-sort on the active one. Change is announced as “up 1.2%” or “down 0.4%”, never colour alone.
- Does Watchlist respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →- Pro
Scrub Sparkline
Data · Refined Savings Goal
Data · RefinedTransaction List
Data · RefinedLeaderboard
Data · Refined- Pro
CPU Monitor
Data · Refined Pulse KPI
Data · Understated