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.