Open-source · MIT · Responsive React

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.

Live example

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.

Total assets
$792,720

Drill into an account, then a transaction. Each level keeps context — no modal, no page change.

Accounts

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.tsx

Tailwind 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> },
  ]}
/>
Two
Slide two

Slide content is just React. Put whatever you want here.

Three
Slide three

Slide content is just React. Put whatever you want here.

Four
Slide four

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.

StackStatusNotes
Next.js (App or Pages)Add "use client" in App router.
Vite + ReactDrop-in.
TanStack StartThis repo is built with it.
Remix / React Router v7Client component (Embla is browser-only).
AstroReact island: client:load or client:visible.
Laravel + Inertia (React)Drop into any Inertia page.
Laravel Blade / Livewire⚠️Needs a React island wrapper.
Vue / Svelte / AngularWould 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>

Live: inline · full page.

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

PropTypeDefaultDescription
eyebrowstringSmall uppercase label above the title.
titlestringLarge widget title.
slidesMultiWidgetSlide[]The slides shown left-to-right.
hasSelectionbooleanTrue when drilled in. Drives blur + prev-button behaviour.
onPrev() => voidWalk one step back in the caller-owned selection.
fullHeightbooleanfalseFill parent height instead of using 440px card.
showCoverbooleantrueShow or hide the cover (first) slide. On mobile a cover with hideOnNarrow renders as a block above the widget automatically.
herobooleanfalseRender as a full-bleed coloured band.
heroBgstringbg-slate-900Tailwind class for the hero band background.
mobileHeaderReactNodeContent shown above the carousel on mobile only.