Skip to content

Frontend

Bento Expandable Stat Cards — Click a KPI Tile, Watch It Expand Into a Chart + Trends in Place

Compact KPI tile becomes a full bento card with chart, comparison, and filters—single click, no page jump. Velocity X's SLT dashboard pattern.

📊

Most dashboards force a choice: show compact tiles on the home page, or drill into a details page. Velocity X's SLT dashboard inverts this. Click a stat tile—revenue, job completion, cost-per-hire—and the card expands in place. The compact KPI becomes a full bento layout: chart on the left, trend sparkline + comparison + filters on the right. One click flips an `expanded` state in Zustand. No page load, no context loss, no "where was I?" moment. This is what dashboard UX feels like when you stop thinking in pages.

Why Expand-in-Place Beats Drill-to-New-Page

The traditional flow: user sees tiles on the dashboard, clicks one, navigates to `/dashboard/revenue-details`, reads the chart, clicks back. Three interactions to answer one question. Context switching kills flow.

Expand-in-place flips the script. The user is already looking at their stat tile. They click it. The card grows, reveals the chart they need, and shrinks back down when they're done. They never left the dashboard—the dashboard revealed itself. This matters for comparison: user wants to know if job completion and team capacity correlate? Expand both cards, look at them side-by-side, all on one screen. Resize the window to mobile? The bento collapses into a vertical stack. One pattern, infinite screens.

The Architecture: Zustand State + CSS Grid + Framer Motion

The core mechanic: a Zustand store holds an `expandedCardId`. Clicking a card's header toggles its expanded state. The card's layout animates via Framer Motion's `layoutId`—the grid reflows, the card grows, and the interior content fades in. CSS handles the grid repositioning; animation handles the visual motion.

{`// store.ts
import { create } from 'zustand';

export const useDashboardStore = create((set) => ({
  expandedCardId: null,
  toggleCard: (cardId) => set((state) => ({
    expandedCardId: state.expandedCardId === cardId ? null : cardId,
  })),
}));`}

The card component uses `expandedCardId` to decide its size and content visibility:

{`const StatCard = ({ id, title, value, trend, chart }) => {
  const { expandedCardId, toggleCard } = useDashboardStore();
  const isExpanded = expandedCardId === id;

  return (
     toggleCard(id)}
    >
      

{title}

{value}
{chart}

7-Day Trend

{trend}

Comparison

+12% vs last week

); };`}

The parent grid uses CSS Grid with auto-placement. When a card expands, `col-span-2 row-span-2` makes it take 2x2 space. Framer Motion's `layoutId` animates the size change—the card doesn't teleport, it smoothly grows into the new space.

Grid Layout Reflow + Bento Positioning

A 4-column grid on desktop holds 8 compact tiles. Click tile #3 (job completion), and it expands to 2 columns wide, 2 rows tall. The surrounding tiles reflow: tiles #4–8 shift down and left to make room. On tablet (2 columns), the expanded card takes the full width plus the row below. Mobile (1 column) becomes a vertical stack—expanded cards still take 2 rows, but the effect is taller, not wider. Same state machine, three different layouts.

{`
{cards.map((card) => ( ))}
// Tailwind responsive: // md: grid-cols-4 // sm: grid-cols-2 // xs: grid-cols-1`}

Keyboard Accessibility

Stat cards are buttons—they should respond to Enter and Space. Use `role="button"` and `tabIndex={0}` on the card. Add an `onKeyDown` handler that checks for Enter (keyCode 13) or Space (keyCode 32) and toggles the same way a click does. Screen reader users hear the card's title and current value; when expanded, they hear the additional details (chart description, trend, comparison). Use ARIA labels: `aria-expanded={isExpanded}` on the card, and `aria-label="Revenue details, expanded"` inside the details section.

{` {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      toggleCard(id);
    }
  }}
  onClick={() => toggleCard(id)}
>
  {/* card content */}
`}

FAQs

Can I expand multiple cards at once? Yes. Switch from `expandedCardId` (single) to `expandedCardIds` (array). Filter the array by checking if the card's id is in the set. Useful for side-by-side comparisons, but be cautious—four expanded 2x2 cards will overflow most screens.

How do I prevent the grid from jumping when a card expands? Use Framer Motion's `layoutId` and ensure the parent has a fixed height or uses `auto-rows-max` so the layout engine recalculates smoothly. Avoid `height: auto` on the card itself—let the grid handle sizing.

What if the chart data is expensive to fetch? Use React Query (`useQuery`) to fetch chart data on-demand when the card expands. Cache it so re-expanding is instant. Pass a `loading` state and show a skeleton inside the expanded card until the data arrives.

Can I save which cards the user expanded? Yes. In the Zustand store, add a persist middleware: `persist(useDashboardStore, { name: 'dashboard-state' })`. On page load, the last expanded card is remembered.

How does this work on mobile with touch? Tap the card to expand; tap again to collapse. No hover states, so add a visual indicator (ring border, shadow) on `:focus` and `:active`. Test on actual devices—Chrome DevTools mobile emulation misses gesture subtleties.

What about animations on slower devices? Wrap animations in `prefers-reduced-motion`. If the user has enabled it, set `transition: none` on all Framer Motion elements. The expand/collapse still works—it just happens instantly instead of smoothly.

The Verdict

Bento expandable cards turn dashboards into tools that think like users. You're not forcing them to jump between pages or memorize chart sequences—you're letting the data expand right where they're looking. Zustand handles state, Framer Motion handles animation, CSS Grid handles layout. If you're building a SaaS dashboard, this is the UX pattern that separates "nice to look at" from "actually useful." See it live in Velocity X's multi-metric chart patterns.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.