EmpireUI
Get Pro
← Blog8 min read#wishlist#favorites#ecommerce

Wishlist UI Design: Heart Toggle, Count Badge, Empty State

Build a production-ready wishlist UI in React — animated heart toggle, count badge, and an empty state that actually converts. With real code.

colorful gradient heart icons arranged on a dark background

Why Most Wishlist UIs Feel Off

You've seen it a hundred times — a grey outlined heart icon sitting in the corner of a product card, doing nothing. No animation. No feedback. No sense that anything happened when you clicked it. Users get confused, double-click to check if it worked, and half the time walk away not knowing if the item got saved.

Honestly, the wishlist toggle is one of the most underestimated interactions in ecommerce. It's the thing between "I might buy this" and "I'll forget this forever." Get it wrong and you lose the sale, not dramatically, just quietly — the user bounces and never comes back.

The fix isn't complicated. What you actually need is three things working together: a toggle with clear animation feedback, a count badge on the nav icon that updates immediately, and an empty state that does some conversion work instead of just showing a sad illustration. This article covers all three, with real React code you can drop in.

Worth noting: the patterns here apply to any "saved" or "favorites" feature, not just ecommerce. Recipe apps, reading lists, portfolio collections — same mechanics. If you want design inspiration while building, the glassmorphism components and style systems over at Empire UI can save you a lot of bikeshedding on visual direction.

The Heart Toggle: State, Animation, and Accessibility

Start with the state. The toggle is binary — wishlisted or not — but the transition between those states is where all the experience lives. Most implementations botch this by toggling a CSS class and calling it a day. You'd get better results using a small spring animation, even a 200ms one makes a massive difference.

Here's a minimal heart toggle component using Framer Motion (v11) with aria attributes handled properly: ``tsx import { motion, AnimatePresence } from 'framer-motion' import { useState } from 'react' type HeartToggleProps = { productId: string initialWishlisted?: boolean onToggle?: (id: string, wishlisted: boolean) => void } export function HeartToggle({ productId, initialWishlisted = false, onToggle }: HeartToggleProps) { const [wishlisted, setWishlisted] = useState(initialWishlisted) const handleToggle = () => { const next = !wishlisted setWishlisted(next) onToggle?.(productId, next) } return ( <button onClick={handleToggle} aria-pressed={wishlisted} aria-label={wishlisted ? 'Remove from wishlist' : 'Add to wishlist'} className="relative w-10 h-10 flex items-center justify-center rounded-full hover:bg-rose-50 dark:hover:bg-rose-950 transition-colors focus-visible:outline-2 focus-visible:outline-rose-500" > <motion.svg viewBox="0 0 24 24" className="w-6 h-6" fill={wishlisted ? '#ef4444' : 'none'} stroke={wishlisted ? '#ef4444' : 'currentColor'} strokeWidth={2} animate={wishlisted ? { scale: [1, 1.35, 1] } : { scale: 1 }} transition={{ duration: 0.25, ease: 'easeOut' }} > <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> </motion.svg> </button> ) } ``

The scale: [1, 1.35, 1] keyframe is the thing that makes it feel alive. Scale to 135% and snap back in 250ms — that's the sweet spot. Go bigger and it feels glitchy. Go slower and the feedback lag kills the feel. You could also add a quick burst particle effect for the "add" action, the same pattern used in Twitter/X's like button, but that's overkill for most product catalogs.

One more thing — don't forget aria-pressed. Screen reader users need to know the toggle state, and aria-label changing between "Add to wishlist" and "Remove from wishlist" gives them the right action context at a glance. It's a two-line fix that makes your component actually accessible.

In practice, you'll want to wire onToggle to an optimistic update pattern — update local state immediately, fire the API call in the background, roll back if it fails. The react-optimistic-updates article covers that pattern in detail if you need it.

Count Badge on the Nav Icon

The badge is the hardest part to get right, not technically, but experientially. It has to update instantly when the user toggles a heart. If there's a 500ms delay between clicking the heart and seeing the badge number tick up, users lose trust. They'll click again. You'll double-add items.

Use a React context or a lightweight store (Zustand is perfect here) to hold the wishlist state globally. The badge reads from the same source as the toggle, so they're always in sync: ``tsx // wishlist-store.ts import { create } from 'zustand' import { persist } from 'zustand/middleware' type WishlistStore = { ids: Set<string> add: (id: string) => void remove: (id: string) => void toggle: (id: string) => void count: () => number } export const useWishlist = create<WishlistStore>()( persist( (set, get) => ({ ids: new Set(), add: (id) => set((s) => ({ ids: new Set([...s.ids, id]) })), remove: (id) => set((s) => { const n = new Set(s.ids); n.delete(id); return { ids: n } }), toggle: (id) => get().ids.has(id) ? get().remove(id) : get().add(id), count: () => get().ids.size, }), { name: 'wishlist', storage: ... } // use localStorage adapter ) ) ``

Then the badge component is just a count with an animated number swap. AnimatePresence with mode="popLayout" handles the number transition cleanly: ``tsx import { motion, AnimatePresence } from 'framer-motion' import { useWishlist } from './wishlist-store' export function WishlistBadge() { const count = useWishlist((s) => s.ids.size) return ( <div className="relative"> <HeartIcon className="w-6 h-6" /> <AnimatePresence mode="popLayout"> {count > 0 && ( <motion.span key={count} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] bg-rose-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1" > {count > 99 ? '99+' : count} </motion.span> )} </AnimatePresence> </div> ) } ``

Quick aside: the key={count} on the badge is intentional. Every count change triggers a remount, which runs the enter animation. It's the simplest way to get that little "pop" each time the number updates. That 18px height and min-w-[18px] combination keeps single-digit and double-digit counts looking right without any layout shift.

Look, persisting to localStorage via Zustand's middleware is genuinely the move here. Users expect their wishlist to survive a page refresh, and syncing to a backend on every toggle creates latency you don't want on the critical interaction path. Sync to the backend on session end or when the user hits the wishlist page.

The Empty State: Don't Waste It

Most empty states are a missed opportunity. You get a sad illustration, some text that says "Your wishlist is empty", and maybe a button back to the homepage. That's a dead end. The user landed on a page, nothing's there, and you're sending them away without a reason to come back.

A good wishlist empty state does three things: it acknowledges the state without making the user feel bad, it shows them what they're missing (featured products, a category grid, trending items), and it gives them a clear, single action to take. Here's a starting structure: ``tsx export function WishlistEmptyState() { return ( <div className="flex flex-col items-center justify-center py-24 px-4 text-center"> <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ type: 'spring', stiffness: 200, damping: 20 }} className="mb-6" > {/* Animated hollow heart */} <svg viewBox="0 0 80 80" className="w-20 h-20 text-rose-200" fill="currentColor"> <path d="M40 70s-28-16.5-28-36a18 18 0 0 1 28-14.9A18 18 0 0 1 68 34c0 19.5-28 36-28 36z" /> </svg> </motion.div> <h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2"> Nothing saved yet </h2> <p className="text-gray-500 max-w-sm mb-8"> Hit the heart on any product to save it here. We won't judge how many you add. </p> <div className="flex gap-3 flex-wrap justify-center"> <a href="/shop" className="btn-primary">Browse products</a> <a href="/collections/trending" className="btn-secondary">See what's trending</a> </div> {/* Optional: show 4 product recommendations */} <FeaturedProductGrid className="mt-16" limit={4} /> </div> ) } ``

That copy — "We won't judge how many you add" — is doing real work. It's a micro-persuasion moment. You're giving permission to wishlist freely, which lowers the threshold to start. Test your copy, seriously. The difference between "Your wishlist is empty" and "Nothing saved yet" is a different emotional register entirely. One's a judgement, one's a status.

The product recommendations below the CTA are optional but worth it. If you have a "trending" or "featured" collection available via your API, rendering four cards here turns a dead end into a discovery page. That's the difference between a 90% bounce rate and actual session continuation.

For styling the empty state itself, you can pull a lot from the gradient generator to create a soft background wash behind the heart illustration — it looks way better than a plain white box.

Connecting Heart Toggle to the Wishlist Page

The toggle, the badge, and the empty state are all worthless if they're not pulling from the same source of truth. That Zustand store from earlier handles the client-side sync. But you also need to think about the server — if users log in on another device, they expect their wishlist to be there.

The simplest pattern: optimistic updates on the client, debounced sync to the server. Here's a hook that wraps the toggle and handles both: ``tsx import { useCallback } from 'react' import { useWishlist } from './wishlist-store' import { useDebouncedCallback } from 'use-debounce' export function useWishlistToggle(productId: string) { const { ids, toggle } = useWishlist() const wishlisted = ids.has(productId) const syncToServer = useDebouncedCallback(async (id: string, state: boolean) => { await fetch('/api/wishlist', { method: state ? 'POST' : 'DELETE', body: JSON.stringify({ productId: id }), headers: { 'Content-Type': 'application/json' }, }) }, 800) const handleToggle = useCallback(() => { toggle(productId) syncToServer(productId, !wishlisted) }, [productId, wishlisted, toggle, syncToServer]) return { wishlisted, handleToggle } } ``

That 800ms debounce means if a user rapidly toggles (it happens), you only fire one API call at the end. Pair this with the HeartToggle component by passing onToggle from this hook and you've got a complete pipeline from click to database.

Worth noting: on the wishlist page itself, load the server state on mount and merge it with local state. If the user wasn't logged in when they wishlisted something, persist those anonymous additions in localStorage and merge them at login. Every ecommerce platform loses conversions by not doing this — the user wishlists something anonymously, logs in, and finds an empty wishlist. They don't re-add. They leave.

Styling Options: From Minimal to Expressive

The minimal approach — solid/outline heart, red fill, spring animation — works for 95% of product contexts. But if your brand has more personality, there's room to push the styling pretty hard without breaking the UX clarity.

For a glassmorphism product card context, the heart toggle sits great at 40x40px with a backdrop-blur pill background. Something like bg-white/20 backdrop-blur-md border border-white/30 gives it that floating quality without competing with the product image. The glassmorphism generator can help you dial in the exact blur and opacity values — you're usually looking at blur(12px) and background: rgba(255,255,255,0.15) as a starting point.

For cyberpunk or neon styles, swap the rose palette for cyan or magenta and add a box-shadow: 0 0 8px currentColor on the active state. The glow makes the "saved" state unmissable. If you want to explore that direction, the cyberpunk style hub has component patterns that work well as a reference.

One thing that crosses all styling contexts: don't make the tap target smaller than 44x44px on mobile. The heart icon itself can be 20–24px, but the button wrapping it needs to be big enough to tap reliably. That's been a WCAG guideline since 2.1 (2018) and it's still something that gets skipped constantly.

Quick aside: if you want a count badge that feels more premium, consider a pill shape instead of a circle for counts above 9. min-w-[22px] with rounded-full keeps it tight but readable at double digits. Single-digit counts look best as true circles.

Performance: Keep It Snappy at Scale

A wishlist feature touches a lot of components simultaneously — every product card on a page has a heart toggle that reads from the same store. With 50 products on a grid page, a naive implementation will re-render all 50 cards every time any single toggle changes. That's a real performance issue.

The fix is selector specificity in Zustand. Each card should only subscribe to its own product's wishlist state, not the whole store: ``tsx // Good — only re-renders if THIS product's state changes const wishlisted = useWishlist((s) => s.ids.has(productId)) // Bad — re-renders every card on every toggle const { ids } = useWishlist() const wishlisted = ids.has(productId) ``

That single change cuts re-renders from O(n) to O(1) per toggle. Combine it with React.memo on your product card component and you're in good shape even with 100-item grids. The react-performance-guide covers this pattern in more depth if you're seeing frame drops in the profiler.

For the Framer Motion animations specifically — keep initial and animate values simple. The scale bounce on the heart is a transform-only animation, which runs on the compositor thread and stays at 60fps regardless of main thread load. The moment you animate width, height, or top/left, you're painting, and that's where you feel it on mid-range devices.

FAQ

Should I save wishlist state to localStorage or the backend?

Both. Use localStorage for anonymous users so state survives page refreshes, then sync to your backend when they log in and merge the two lists. Losing anonymous wishlist data at login is a known conversion killer.

What's the right animation duration for the heart toggle?

200–250ms is the sweet spot for the scale bounce. Under 150ms feels instant and users miss it; over 300ms starts feeling sluggish. Use a spring or ease-out curve, not linear.

How do I handle the count badge when wishlist count goes above 99?

Render '99+' as a string for any count over 99. Your badge needs min-w not a fixed width so it expands — min-w-[18px] with px-1 padding keeps it readable at any length.

Is aria-pressed enough for screen reader accessibility on the heart toggle?

Yes, paired with a dynamic aria-label. Change the label between 'Add to wishlist' and 'Remove from wishlist' based on state so screen reader users get the current action, not just the pressed state.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

E-Commerce Product Card Design: 8 Layouts That Actually ConvertUI Microinteractions in 2026: The Small Details That Make Users StayStar Rating Component in React: Animated, Half Stars, Read-OnlyE-Commerce Product Page in Tailwind: Gallery, Options, CTA