Skip to content
KONIGI Patterns

Dashboards / Organization and navigation / Tabs as genres

‹ 4 of 4

Tabs as genres

People need distinct views for the different questions they ask of the same data.

Updated September 10, 2026

Problem

The same data serves different questions, and combining every view on one page makes each task harder to follow.

Solution

Create a small, stable set of tabs named for the questions or tasks they support. Use terms the audience recognizes rather than database table names.

Keep shared time ranges and filters consistent between tabs. Make the active tab clear and support direct links to each view.

Give Overview a focused summary and links to useful next steps. When the set grows too large for readable tabs, move to a navigation structure with more room.

Use when

A dataset supports several distinct, recurring kinds of question that can be named clearly.

Don’t use when

The tabs would be sequential steps rather than parallel questions, or the set keeps growing. A tab strip that gained three entries this year is a navigation problem being deferred.

Trade-offs

Tabs hide the other views and make cross-view comparison harder. Names become familiar and costly to change. Equal tab styling can also imply equal importance when one view is used far more often.

Checklist

  • Are the tabs kinds of question, or names of data tables?
  • Would the intended reader recognise their question in a tab name?
  • How many tabs, and is the set stable?
  • Does the Overview tab route people, or is it a leftovers drawer?
  • Do time range and filters carry across tabs?
  • Does the URL identify the tab, so a link opens the right one?
  • Which tab gets the traffic, and do the others justify their place?
  • Can a reader tell what is on a tab without opening it?
  • What is the process for adding a tab, and has it been used recently?
  • Would a sidebar serve better at this count?

Compare

Google Analytics popularised this arrangement for web data and shows both sides of it: the genre names are recognisable to non-experts, and the set has grown across versions until the strip became a tree. Plausible deliberately refuses the pattern, keeping everything on one page, which works because it carries far fewer metrics and is a direct statement that tabs are a symptom of scope. Sentry uses top-level genres—Issues, Performance, Releases—that are genuinely different questions about the same events, which is the pattern applied correctly at product scale. Grafana has no tab primitive within a dashboard, so genre tabs are separate dashboards linked in a row, and keeping them consistent is entirely manual.

Multi-page dashboard covers the broader page set. Semantic grouping organizes related content within a page. Sidebar and canvas supports more destinations.

Single-column narrative fits one guided sequence. Saved view supports recurring questions not covered by the tabs.

Tabs as genres anatomy A page with a shared header carrying the time range and filters, a strip of five named tabs below it with one active, and the view for that tab. Below, the same strip named from the data model and named from the reader's questions. Anatomy Overview Content Audience Revenue Sources 1 2 3 1 SHARED STATE Time range and filters live above the strip and carry across. Without that they are four dashboards that share a header. 2 FOUR TO SIX A set a person learns, and stable enough to build muscle memory on. Nine is a menu, and a menu should be a sidebar. 3 THE OVERVIEW TAB Not the place for panels that didn't fit elsewhere. Its job is to answer the top question and route people to the right tab. Named from the data model Events Sessions Users Pageviews Named from the question Overview Content Audience Revenue
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The same data serving different questions, each with its own page. Tabs named after questions beat tabs named after data sources, because the reader arrives with a question rather than a table name.

shadcn
npx shadcn@latest add tabs button dropdown-menu
npm
lucide-react
Tokens
--card--card-foreground--muted--muted-foreground--border--chart-1

konigi.com · all posts

184,300 readers this week, up 6% on the week before.

GenreTabs.tsxThree to five genres plus Overview, each required to state the question it answers and its one-line answer. Overview is built from those answers, so it routes. Range and filters sit above the strip and reach every tab.

import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

/** What every tab receives. Range and filters live above the strip and
 *  carry across; a tab that kept its own would be a separate dashboard. */
export type Shared = { range: string; filter: string };

export type Genre = {
  id: string;
  /** Named from the reader's question, never from a table. */
  name: string;
  /** The question, spelled out. Required: a tab that cannot state the
   *  question it answers is named after a data source. Shown under the
   *  strip so a reader knows what is on a tab before opening it. */
  question: string;
  /** This tab's one-line answer right now. The overview is built from
   *  these, which is what makes it a router rather than a leftovers drawer. */
  answer: string;
  view: (shared: Shared) => ReactNode;
};

/** With Overview, four to six tabs. Nine is a menu, and a menu is a sidebar. */
export type Genres = [Genre, Genre, Genre] | [Genre, Genre, Genre, Genre] | [Genre, Genre, Genre, Genre, Genre];

export const RANGES = ["24h", "7d", "28d", "90d"] as const;

export function GenreTabs({ genres, topline, shared, onShared, onFilter, tab, onTab }: {
  genres: Genres;
  /** The top question's answer, for the reader who wants one number. */
  topline: ReactNode;
  shared: Shared;
  onShared: (next: Shared) => void;
  onFilter: () => void;
  /** "overview" or a genre id. Put it in the URL so a link opens the right tab. */
  tab: string;
  onTab: (id: string) => void;
}) {
  const current = genres.find((g) => g.id === tab);

  return (
    <div className="rounded-lg border bg-card">
      <div className="flex items-center gap-2 border-b px-3 py-2">
        <p className="text-xs text-muted-foreground">{shared.filter}</p>
        <DropdownMenu modal={false}>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" size="sm" className="ml-auto h-6 px-2 text-[11px]">last {shared.range} <ChevronDown className="size-3" /></Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="min-w-24">
            {RANGES.map((r) => <DropdownMenuItem key={r} className="text-xs" onSelect={() => onShared({ ...shared, range: r })}>last {r}</DropdownMenuItem>)}
          </DropdownMenuContent>
        </DropdownMenu>
        <Button variant="outline" size="sm" className="h-6 px-2 text-[11px]" onClick={onFilter}>filter</Button>
      </div>

      <Tabs value={tab} onValueChange={onTab}>
        <TabsList className="h-9 w-full justify-start gap-3 rounded-none border-b bg-transparent px-3">
          <TabsTrigger value="overview" className="rounded-none border-b-2 border-transparent px-0 text-xs data-[state=active]:border-chart-1 data-[state=active]:shadow-none">Overview</TabsTrigger>
          {genres.map((g) => (
            <TabsTrigger key={g.id} value={g.id} title={g.question} className="rounded-none border-b-2 border-transparent px-0 text-xs data-[state=active]:border-chart-1 data-[state=active]:shadow-none">{g.name}</TabsTrigger>
          ))}
        </TabsList>
      </Tabs>

      <div className="p-3">
        {current ? (
          <>
            <p className="text-xs text-muted-foreground">{current.question}</p>
            <p className="mt-1 text-sm text-card-foreground">{current.answer}</p>
            <div className="mt-3">{current.view(shared)}</div>
          </>
        ) : (
          <>
            {/* Answer the top question, then route. */}
            <div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${genres.length}, minmax(0, 1fr))` }}>
              {genres.map((g) => (
                <button key={g.id} type="button" onClick={() => onTab(g.id)} className="rounded border bg-muted p-2.5 text-left hover:border-foreground/40">
                  <p className="text-xs text-card-foreground">{g.name}</p>
                  <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{g.answer}</p>
                </button>
              ))}
            </div>
            <div className="mt-3 rounded border p-3">{topline}</div>
          </>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called: Content, Audience, Revenue and Sources for one publication, landing on Overview. Hover a tab for its question.

import { useState } from "react";
import { GenreTabs, type Genres, type Shared } from "./GenreTabs";

const figure = (label: string, value: string) => (
  <div className="rounded border bg-muted px-3 py-2">
    <p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
    <p className="text-lg tabular-nums text-card-foreground">{value}</p>
  </div>
);
const grid = (...items: ReturnType<typeof figure>[]) => <div className="grid grid-cols-2 gap-2">{items}</div>;

/** One publication's week, four readers. The editor, the growth lead, the
 *  ad ops lead and whoever wants to know where people came from. */
const GENRES: Genres = [
  { id: "content", name: "Content", question: "What did we publish, and how did it do?", answer: "Nine posts; three passed 20k reads and the Okafor interview did 41k.", view: (s) => grid(figure(`posts, last ${s.range}`, "9"), figure("top post reads", "41,200")) },
  { id: "audience", name: "Audience", question: "Who are they, and are they coming back?", answer: "184k readers, 62% new; returning readers up 4 pts.", view: (s) => grid(figure(`readers, last ${s.range}`, "184,300"), figure("returning", "38%")) },
  { id: "revenue", name: "Revenue", question: "What did it earn?", answer: "£12,400, with ad fill at 91% and 212 new members.", view: (s) => grid(figure(`revenue, last ${s.range}`, "£12,400"), figure("new members", "212")) },
  { id: "sources", name: "Sources", question: "Where did readers come from?", answer: "Search 48%, newsletter 22%, social 14%; search up 9 pts.", view: (s) => grid(figure(`search share, last ${s.range}`, "48%"), figure("newsletter share", "22%")) },
];

/** Lands on Overview. The range in the header is the range on every tab. */
export default function Demo() {
  const [shared, setShared] = useState<Shared>({ range: "7d", filter: "konigi.com · all posts" });
  const [tab, setTab] = useState("overview");
  return (
    <GenreTabs
      genres={GENRES}
      topline={
        <p className="text-sm text-card-foreground">
          <span className="text-2xl tabular-nums">184,300</span> readers this week, up 6% on the week before.
        </p>
      }
      shared={shared}
      onShared={setShared}
      onFilter={() => {}}
      tab={tab}
      onTab={setTab}
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

User Funnel & Conversion Rates September 11, 2026 Tableau Public embed view; workbook published by Tetiana Berezhna medium · light · desktop-web
This published workbook shows overall conversion at each funnel step but omits conversion from the preceding step. Readers must calculate that themselves to compare losses between stages. The tapered rendering makes comparisons harder than aligned bars would. Another panel places rates based on very different registration counts at the same visual weight, showing why the denominator matters.
  • Funnel The tapered shape makes step quantities harder to compare than aligned bars.
  • Funnel Only overall conversion is labeled; step-to-step conversion requires calculation.
  • Ratio and rate Similar-looking rates are based on very different registration counts.
  • Header KPI strip Summary counts appear without a comparison value.
  • Tabs as genres Worksheet tabs use metric names that may need explanation.
Show 2 more examples Hide the rest

Honeycomb

Query-driven views use heatmaps and selected regions to investigate differences between requests.

Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
This sandbox heatmap uses a linear vertical scale. Most requests sit near the bottom, while a separate group of slower requests extends upward later in the period. The view exposes variation that a single summary value would hide, though a different scale could give the lower band more room. The footer reports query duration and rows examined. A cookie banner and onboarding dialog were removed for the capture; the dashboard itself was left intact.

Plausible Analytics

A metric row controls the main chart, followed by focused traffic breakdowns.

Live demo / plausible.io September 10, 2026 Plausible live demo, plausible.io's own stats (signed out) medium · light · desktop-web
The metric row controls the main chart: the selected tile is outlined and its series appears below. Supporting sections then break the results down by source, page, geography, and other categories. Ranked lists show counts and bars, but no visible share of the total. The map uses very pale colors for most countries, limiting how much variation is easy to see.