Bismillâh. Yesterday the Add-to-cart button showed a friendly
confirmation and moved on. Today we wire it to a real counter: a
tiny badge in the top-right of the shop that remembers what you
picked, across refreshes, across tab closes, even across a week
offline. Twelve lines of JavaScript. No backend. No framework. Just
the browser's oldest small-database:
localStorage.
Tap any product above, watch the badge bump and the total climb. Refresh the preview. The numbers are still there.
1. localStorage in ten seconds
localStorage is a key-value store built into every
browser. Each site gets about 5 MB. Strings in, strings out. No
auth, no promises, no cursors. Two methods and one key to
remember:
localStorage.setItem('shop:cart', JSON.stringify({ 'walnut-table': 2 }))
localStorage.getItem('shop:cart') // the raw string
JSON.parse(localStorage.getItem('shop:cart') || '{}') // back to an object
That's the whole API for our purposes. Write it down, forget it, come back whenever you need a feature that should survive a refresh.
2. The shape of a cart
Before any code, decide what a cart is. Options:
- An array of items:
[{ sku, qty }, { sku, qty }]. Flexible but awkward for lookups. - A map from sku to qty:
{ "walnut-table": 2, "linen-lamp": 1 }. Simple, fast, natural for "add one more of this."
For a counter+total like ours, the map is better. Adding one more
of X is cart[X] = (cart[X] || 0) + 1, no searching
through an array. When we need line items later for a real cart
drawer, we'll join the map with the product catalog.
3. Twelve lines that do the work
const KEY = 'shop:cart'
let cart = JSON.parse(localStorage.getItem(KEY) || '{}')
const badge = document.querySelector('#badge')
function render() {
const count = Object.values(cart).reduce((a, b) => a + b, 0)
badge.textContent = count
badge.style.opacity = count > 0 ? '1' : '0.3'
}
document.querySelectorAll('.add').forEach(btn => {
btn.addEventListener('click', () => {
const sku = btn.dataset.sku
cart[sku] = (cart[sku] || 0) + 1
localStorage.setItem(KEY, JSON.stringify(cart))
render()
})
})
render()
Read it top to bottom:
-
Namespace the key.
'shop:cart'is better than just'cart'. When the site grows a wishlist, a recently-viewed list, or a saved coupon, each gets its own prefix and they never collide. -
Parse with a fallback.
JSON.parse(... || '{}')handles the first visit (no stored cart yet) without a try/catch. If you worry about corrupted data, wrap it in try/catch, but for a client-side cart the risk is small. -
Sum with reduce.
Object.values(cart).reduce((a, b) => a + b, 0)turns the qty map into a single number. It reads badly the first time, beautifully the third. -
btn.dataset.skureadsdata-sku="walnut-table"off the HTML. Every Add button carries its own sku; no lookup dictionary needed. -
Save after every change.
localStorage.setItem(KEY, JSON.stringify(cart))writes the whole cart on each click. It's fine;localStorageis synchronous and fast for small objects like this.
4. A badge that pulses when you add
A silent counter is a counter the user doesn't trust. The demo adds a tiny bump animation to the badge on every click: scale up to 1.35, scale back to 1, 0.4 seconds total. A class toggle, a keyframe, done.
.badge.bump { animation: bump 0.4s ease-out; }
@keyframes bump {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
function bump() {
badge.classList.remove('bump')
void badge.offsetWidth // force reflow so the animation restarts
badge.classList.add('bump')
}
The void badge.offsetWidth line is the classical CSS
trick for replaying an animation. Removing the class, reading a
layout property, then re-adding the class makes the browser flush
its state so the animation kicks off anew. Without that reflow,
rapid clicks would all happen "during" one animation and the
reader wouldn't see the bump.
5. The total, the clear
Counting quantity is one number. Summing price is another. Our
render function does both by walking the Add buttons and reading
data-price off each:
let count = 0, sum = 0
document.querySelectorAll('.add').forEach(btn => {
const sku = btn.dataset.sku
const price = Number(btn.dataset.price)
const qty = cart[sku] || 0
count += qty
sum += qty * price
})
total.textContent = '$' + sum.toLocaleString('en-US')
toLocaleString('en-US') gives you 1,234 instead of
1234. Later in this series we'll reach for the full
Intl.NumberFormat to format currencies across locales,
but for now a single call suffices.
The Clear button is even smaller:
document.querySelector('#clear').addEventListener('click', () => {
cart = {}
localStorage.removeItem(KEY)
render()
})
Tricks worth carrying
- Treat the DOM as the source of truth for display, localStorage for state. On every change, save the state, then re-render from it. Resist temptation to edit the DOM incrementally.
-
Namespace localStorage keys.
shop:cart,shop:wishlist,auth:token. Collisions are silent and painful. - Opacity, not visibility. Badge at qty=0 fades to 30% rather than disappearing, so the icon's layout stays stable and the cart always looks reachable.
-
The
storageevent. If you want two tabs to stay in sync, listen forwindow.addEventListener('storage', handler). It fires in every other tab when a key changes. Real-time cart sync, no WebSocket. We'll use this in the inventory post later in the series. -
Don't store secrets.
localStorageis plain text in the browser. User prefs, cart contents, recently-viewed lists: fine. Passwords, tokens that shouldn't hit JavaScript, private data: never.
On careful hands
A shopping cart is a small emanet. The reader puts things in trusting that the numbers add up and the site won't forget. A cart that resets on refresh tells them "we don't care about your time"; a cart that remembers says the opposite. Twelve lines of JavaScript is enough to keep that small promise, across cafes, across devices (with a bit more work), and across an afternoon of browsing. Ölçü, measure, applies here too: count correctly, price honestly, clear cleanly.
Takeaways
localStorageis a 5 MB key-value store. Two methods cover 99% of use cases.- Pick a cart shape that matches your access pattern. For counting by sku, a map wins.
- Save state, re-render from it. Don't keep two copies of truth.
- Small animations (bump, fade) are user-trust signals, not decoration.
Next post: "One CSS rule for a shop grid that just works" — mobile, tablet, desktop, no media queries, no framework.
Count honestly. Save honestly. Ship honestly, kardeşim.