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.sku reads data-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; localStorage is 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 storage event. If you want two tabs to stay in sync, listen for window.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. localStorage is 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

  1. localStorage is a 5 MB key-value store. Two methods cover 99% of use cases.
  2. Pick a cart shape that matches your access pattern. For counting by sku, a map wins.
  3. Save state, re-render from it. Don't keep two copies of truth.
  4. 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.