Data Drill-Down Widget
A responsive React component that lets users dive into levels of complex data — dashboards, taxonomies, geo-data, finance reports. One file. Three deps. MIT licensed.
Made by Joachim Blicher for myntr.dk.
Three levels of finance data, one widget
Click an account, then a transaction. The widget walks you in and back out — and stays responsive on mobile.
Drill into an account, then a transaction. Each level keeps context — no modal, no page change.
Why this exists
I built this while working on myntr.dk, a finance product where users need to navigate deeply nested data: accounts → categories → transactions → splits. Traditional tables and accordions felt heavy and broke down on mobile.
I wanted an interactive, almost playful, way for users to dive into complex financial data — one level at a time, with full context still visible. Nothing off the shelf did exactly that, so I open-sourced what I built.
— Joachim Blicher
Quick start
The package is a single React file: src/components/multi-widget.tsx. It depends only on react, embla-carousel-react and lucide-react. Install the deps and copy the file in.
# 1. Install peer deps
bun add react embla-carousel-react lucide-react
# or: npm install / pnpm add / yarn add
# 2. Copy the component
curl -o src/components/multi-widget.tsx \
https://raw.githubusercontent.com/<you>/multi-widget/main/src/components/multi-widget.tsxTailwind CSS (v3 or v4) is required — all styling is utility classes. Then import and render:
import { MultiWidget } from "@/components/multi-widget";
<MultiWidget
eyebrow="Inbox"
title="Today"
hasSelection={false}
onPrev={() => {}}
slides={[
{ key: "a", content: <div className="p-4">Slide A</div> },
{ key: "b", content: <div className="p-4">Slide B</div> },
{ key: "c", content: <div className="p-4">Slide C</div> },
]}
/>Slide content is just React. Put whatever you want here.
Slide content is just React. Put whatever you want here.
Slide content is just React. Put whatever you want here.
Works with any React stack
MultiWidget is a plain React 18/19 component — no framework lock-in. Drop it into any setup that renders React + Tailwind.
| Stack | Status | Notes |
|---|---|---|
| Next.js (App or Pages) | ✅ | Add "use client" in App router. |
| Vite + React | ✅ | Drop-in. |
| TanStack Start | ✅ | This repo is built with it. |
| Remix / React Router v7 | ✅ | Client component (Embla is browser-only). |
| Astro | ✅ | React island: client:load or client:visible. |
| Laravel + Inertia (React) | ✅ | Drop into any Inertia page. |
| Laravel Blade / Livewire | ⚠️ | Needs a React island wrapper. |
| Vue / Svelte / Angular | ❌ | Would need a port — Embla wiring is small. |
Hard requirements: React 18+ and Tailwind CSS. Nothing else.
What kind of data can I pass in?
Anything renderable. Each slide accepts a content: React.ReactNode, so you can put text, lists, forms, charts, images, nested components — whatever. The widget itself owns no data; you build slides from your own state.
type MultiWidgetSlide = {
key: string; // stable react key
content: React.ReactNode; // anything you want to render
tabTitle?: string; // small label at the top of the slide
onBack?: () => void; // shows a back arrow (top-left)
onEdit?: () => void; // shows a "..." button (top-right)
editLabel?: string; // aria-label for the edit button
variant?: "default" | "dark";
size?: "1/4" | "1/3" | "1/2" | "2/3" | "3/4" | "full"; // desktop width
wide?: boolean; // shortcut for size: "2/3"
hideOnNarrow?: boolean; // hide this slide on small containers
};To "throw data at it", map your array into slides and let React reconcile by key:
const slides = items.map((item) => ({
key: item.id,
tabTitle: item.title,
content: <ItemBody item={item} />,
}));Per-slide options (edit, back, label)
Each slide can opt in to chrome elements. They render as sticky overlays on top of the slide content.
onBack— a circular back arrow in the top-left corner.onEdit+editLabel— a "•••" button in the top-right corner.tabTitle— a small uppercase label centred at the top, separated from the body by a hairline.variant: "dark"— a dark slide background (used for overview / hero slides).
{
key: "edit-me",
tabTitle: "Settings",
onBack: () => navigate(-1),
onEdit: () => openEditor(),
editLabel: "Edit settings",
content: <Settings />,
}See it live: inline drill-down demo.
Slide widths
On desktop, slides take ~1/3 of the container. Pass wide to make a slide take ~2/3 instead — useful for a "detail" slide. Pass hideOnNarrow to hide a slide on small containers; combine with the mobileHeader prop to render an alternative on mobile.
slides={[
{ key: "overview", variant: "dark", hideOnNarrow: true, content: <Overview /> },
{ key: "list", content: <List /> },
{ key: "detail", wide: true, content: <Detail /> },
]}Layout: inline vs full-page
By default the widget renders as an inline card with a fixed height of 440pxand rounded corners. To make it fill its parent (a full-page explorer, for example), pass fullHeight. The parent must be a flex/height context.
// Inline (default)
<MultiWidget {...props} />
// Full page — fills parent height, no fixed 440px
<div className="h-screen flex flex-col">
<MultiWidget {...props} fullHeight />
</div>Hero band
Pass hero to render the widget as a full-bleed coloured band. The first slide becomes transparent so the band colour shows through it. Customise the colour with heroBg (a Tailwind class).
<MultiWidget {...props} hero heroBg="bg-indigo-700" />Live: hero demo.
Selection / drill-down
The widget itself is stateless about what is selected. You own the selection state and rebuild the slides array when it changes. Tell the widget about it with two props:
hasSelection: boolean— true when the user has drilled past the first slide. This blurs the overview slide and changes the prev button to walk back in your hierarchy.onPrev: () => void— called when the user clicks the prev button or scrolls back to the first slide.
const [selected, setSelected] = useState<Item | null>(null);
const slides = [
{ key: "overview", variant: "dark", content: <Overview /> },
{ key: "list", content: <List onPick={setSelected} /> },
...(selected ? [{ key: selected.id, wide: true, content: <Detail item={selected} /> }] : []),
];
<MultiWidget
slides={slides}
hasSelection={selected !== null}
onPrev={() => setSelected(null)}
{...rest}
/>Need more depth? See the 2-to-5 level demo, or try the live playground — paste bullet text and see it as a drill-down at any depth.
Props reference
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | — | Small uppercase label above the title. |
| title | string | — | Large widget title. |
| slides | MultiWidgetSlide[] | — | The slides shown left-to-right. |
| hasSelection | boolean | — | True when drilled in. Drives blur + prev-button behaviour. |
| onPrev | () => void | — | Walk one step back in the caller-owned selection. |
| fullHeight | boolean | false | Fill parent height instead of using 440px card. |
| showCover | boolean | true | Show or hide the cover (first) slide. On mobile a cover with hideOnNarrow renders as a block above the widget automatically. |
| hero | boolean | false | Render as a full-bleed coloured band. |
| heroBg | string | bg-slate-900 | Tailwind class for the hero band background. |
| mobileHeader | ReactNode | — | Content shown above the carousel on mobile only. |