Wishlist
A saved-items list with price-drop tags, sort by recent or price, move to bag, and remove with an undo toast.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/wishlist.json'use client'
import { useEffect, useRef, useState } from 'react'
import './wishlist.css'
export interface WishItem { id: string; name: string; price: number; was?: number; note?: string }
export interface WishlistProps {
items: WishItem[]
sort?: 'recent' | 'price'
onMove?: (id: string) => void
onRemove?: (id: string) => void
fill?: 'dark' | 'light'
className?: string
}
/** Saved items with price drops, sort and undo. */
export function Wishlist({ items: i0, sort: s0 = 'recent', onMove, onRemove, fill = 'dark', className }: WishlistProps) {
const [items, setItems] = useState(i0)
const [sort, setSort] = useState(s0)
const [out, setOut] = useState<string | null>(null)
const [toast, setToast] = useState<{ msg: string; undo?: { item: WishItem; at: number } } | null>(null)
const t = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(t.current), [])
const m = (n: number) => `$${n.toFixed(2)}`
const shown = sort === 'price' ? [...items].sort((a, b) => a.price - b.price) : items
const drop = (x: WishItem, kind: 'move' | 'remove') => {
setOut(x.id)
setTimeout(() => {
const at = items.findIndex((y) => y.id === x.id)
setItems((xs) => xs.filter((y) => y.id !== x.id)); setOut(null)
setToast(kind === 'move' ? { msg: `${x.name} moved to bag` } : { msg: `Removed ${x.name}`, undo: { item: x, at } })
kind === 'move' ? onMove?.(x.id) : onRemove?.(x.id)
clearTimeout(t.current); t.current = setTimeout(() => setToast(null), 3500)
}, 200)
}
return (
<div className={['sd-wsh', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-wsh__h"><b>Saved<small>{items.length} items</small></b>
<div className="sd-wsh__sort" role="radiogroup" aria-label="Sort">{(['recent', 'price'] as const).map((s) => <button key={s} type="button" role="radio" aria-checked={sort === s} onClick={() => setSort(s)}>{s === 'recent' ? 'Recent' : 'Price'}</button>)}</div></div>
<ul>
{shown.map((x) => (
<li key={x.id} data-out={out === x.id || undefined}>
<span className="sd-wsh__img" aria-hidden>{x.name[0]}</span>
<div className="sd-wsh__n"><span>{x.name}</span>
<div><b>{m(x.price)}</b>{x.was && x.was > x.price && <><s>{m(x.was)}</s><em>−{Math.round((1 - x.price / x.was) * 100)}%</em></>}</div>
{x.note && <small>{x.note}</small>}</div>
<div className="sd-wsh__a"><button type="button" aria-label={`Move ${x.name} to bag`} onClick={() => drop(x, 'move')}>To bag</button><button type="button" aria-label={`Remove ${x.name}`} onClick={() => drop(x, 'remove')}>Remove</button></div>
</li>
))}
{!items.length && <li className="sd-wsh__empty" style={{ boxShadow: 'none', display: 'block' }}>Nothing saved yet</li>}
</ul>
{toast && <div className="sd-wsh__toast" role="status"><span>{toast.msg}</span>{toast.undo && <button type="button" onClick={() => { const u = toast.undo!; setItems((xs) => { const n = [...xs]; n.splice(u.at, 0, u.item); return n }); setToast(null) }}>Undo</button>}</div>}
</div>
)
}.sd-wsh { --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-wsh[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-wsh :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-wsh :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-wsh .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-wsh { width: min(100%, 330px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; position: relative; }
.sd-wsh__h { display: flex; align-items: center; justify-content: space-between; }
.sd-wsh__h b { font-size: 15px; }
.sd-wsh__h b small { color: var(--mute); font-weight: 400; margin-left: 6px; font-size: 12.5px; }
.sd-wsh__sort { display: flex; padding: 2px; border-radius: 9px; background: var(--soft); }
.sd-wsh__sort button { padding: 4px 9px; border-radius: 7px; font-size: 11.5px; color: var(--mute) !important; }
.sd-wsh__sort button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); }
.sd-wsh ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; min-height: 250px; align-content: start; }
.sd-wsh li { display: flex; gap: 10px; align-items: center; padding: 8px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); transition: transform 220ms, opacity 220ms; }
.sd-wsh li[data-out] { transform: translateX(40px); opacity: 0; }
.sd-wsh__img { width: 46px; height: 46px; flex: none; border-radius: 9px; background: var(--soft); display: grid; place-items: center; font-size: 18px; font-weight: 700; color: var(--mute); }
.sd-wsh__n { flex: 1; min-width: 0; }
.sd-wsh__n span { display: block; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-wsh__n div { font-size: 12.5px; font-variant-numeric: tabular-nums; display: flex; gap: 6px; align-items: baseline; }
.sd-wsh__n s { color: var(--mute); font-size: 11.5px; }
.sd-wsh__n em { font-style: normal; font-size: 10.5px; font-weight: 700; padding: 1px 6px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; }
.sd-wsh__n small { display: block; color: var(--mute); font-size: 11px; }
.sd-wsh__a { display: grid; gap: 4px; }
.sd-wsh__a button { height: 26px; padding: 0 9px; border-radius: 7px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-wsh__a button:first-child { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-wsh__toast { position: absolute; left: 14px; right: 14px; bottom: 14px; display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-size: 12.5px; animation: sd-wsh-t 200ms ease-out; }
@keyframes sd-wsh-t { from { opacity: 0; transform: translateY(8px); } }
.sd-wsh__toast button { font-weight: 700; text-decoration: underline; color: inherit !important; }
.sd-wsh__empty { padding: 50px 0; text-align: center; color: var(--mute); font-size: 13px; }
@media (prefers-reduced-motion: reduce) { .sd-wsh *, .sd-wsh *::before, .sd-wsh *::after { animation: none !important; transition: none !important; } }Overview
Each item shows its current price and, when it has dropped, the old price struck through with the saving. Sort switches between most recent and lowest price.
Move to bag and remove both slide the row out; remove keeps an Undo for a few seconds.
When to use
- Store accounts, saved-for-later beneath a cart.
When not to use
- Registries shared with others: add owner and claimed states.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/wishlist.json$
npx shadcn@latest add https://sundaeui.dev/r/wishlist.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/wishlist.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/wishlist.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 wishlist.tsx and wishlist.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 { Wishlist } from '@/components/sundae/wishlist'
<Wishlist />Props
| Prop | Type | Default |
|---|---|---|
items*Saved items. | { id: string; name: string; price: number; was?: number; note?: string }[] | — |
onMoveCalled on move to bag. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Sort is a radiogroup; each row action is labelled with the item name.
- Removal and moves are announced with an undo.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-wsh; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Wishlist free?
- Yes. Wishlist is free for personal and commercial projects, with full source on this page.
- How do I install Wishlist?
- Run npx shadcn@latest add https://sundaeui.dev/r/wishlist.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Wishlist support keyboard and screen readers?
- Sort is a radiogroup; each row action is labelled with the item name. Removal and moves are announced with an undo.
- Does Wishlist respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Swipe Actions
Components · Refined - Pro
Sortable List
Components · Refined - Pro
Bulk Actions Bar
Components · Refined Grid / List View Switcher
Components · RefinedPull to Refresh
Components · RefinedLoad More List
Components · Refined