Monet
@etched/monet 0.0.1

Tabs

Ported

A row of tabs that switches views, with their TabsPanels or with the view the app renders. Tabs that do not fit scroll sideways: the ends fade, and buttons at the ends scroll by most of a width for a pointer (keys and touch scroll the list themselves). The selected tab scrolls into view when it mounts and when it changes.

Kumo source
import { Tabs, type TabsItem } from "@etched/monet/tabs";

const sections: TabsItem[] = [
  { value: "overview", label: "Overview" },
  { value: "runs", label: "Runs" },
  { value: "owners", label: "Owners" },
];

export function Preview() {
  return <Tabs aria-label="Chip sections" tabs={sections} />;
}

Installation

import { Tabs, TabsNav, TabsPanel } from "@etched/monet/tabs";

Examples

Segmented and underline

import { Tabs, type TabsItem } from "@etched/monet/tabs";

const sections: TabsItem[] = [
  { value: "overview", label: "Overview" },
  { value: "runs", label: "Runs" },
  { value: "owners", label: "Owners" },
];

export function SegmentedAndUnderline() {
  return (
    <div className="grid">
      <Tabs aria-label="Chip sections" tabs={sections} />
      <Tabs aria-label="Chip sections, underline" variant="underline" tabs={sections} defaultValue="runs" />
    </div>
  );
}

Small

import { Tabs, type TabsItem } from "@etched/monet/tabs";

const sections: TabsItem[] = [
  { value: "overview", label: "Overview" },
  { value: "runs", label: "Runs" },
  { value: "owners", label: "Owners" },
];

export function Small() {
  return (
    <div className="grid">
      <Tabs aria-label="Chip sections, small" size="sm" tabs={sections} />
      <Tabs aria-label="Chip sections, small underline" size="sm" variant="underline" tabs={sections} />
    </div>
  );
}

Controlled view switch

Showing the pipeline view.

import { useState } from "react";
import { Tabs, type TabsItem } from "@etched/monet/tabs";

const views: TabsItem[] = [
  { value: "pipeline", label: "Pipeline" },
  { value: "summary", label: "Summary" },
];

export function ControlledViewSwitch() {
  const [view, setView] = useState("pipeline");

  return (
    <div className="grid">
      <Tabs aria-label="Role views" tabs={views} value={view} onValueChange={setView} />
      <p>Showing the {view} view.</p>
    </div>
  );
}

A disabled tab

import { Tabs, type TabsItem } from "@etched/monet/tabs";

const settings: TabsItem[] = [
  { value: "users", label: "Users" },
  { value: "notifications", label: "Notifications" },
  { value: "data", label: "Data quality", disabled: true },
];

export function ADisabledTab() {
  return <Tabs aria-label="Settings" tabs={settings} />;
}

Overflow

Tabs that do not fit scroll sideways, and their ends fade. Buttons at the ends scroll for a pointer. The selected tab scrolls into view when it mounts, as in the underline row, and when the app changes it.

import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Tabs, type TabsItem } from "@etched/monet/tabs";

const many: TabsItem[] = ["Overview", "Runs", "Thermal", "Power", "Logs", "Owners", "History", "Alerts", "Settings"].map((label) => ({
  value: label.toLowerCase(),
  label,
}));

export function Overflow() {
  const [view, setView] = useState("overview");
  const at = many.findIndex((tab) => tab.value === view);
  const move = (by: number) => setView(many[at + by]?.value ?? view);

  return (
    <div className="grid">
      <Tabs aria-label="Chip views" tabs={many} value={view} onValueChange={setView} />
      <div className="row">
        <Button size="sm" disabled={at === 0} onClick={() => move(-1)}>
          Previous view
        </Button>
        <Button size="sm" disabled={at === many.length - 1} onClick={() => move(1)}>
          Next view
        </Button>
      </div>
      <Tabs aria-label="Chip views, underline" variant="underline" tabs={many} defaultValue="alerts" />
    </div>
  );
}

Vertical

A column of tabs beside its panels, such as settings sections. Up and Down move between the tabs.

Who can see and edit candidates.

import { Tabs, TabsPanel, type TabsItem } from "@etched/monet/tabs";

const sections: TabsItem[] = [
  { value: "overview", label: "Overview" },
  { value: "runs", label: "Runs" },
  { value: "owners", label: "Owners" },
];

const settings: TabsItem[] = [
  { value: "users", label: "Users" },
  { value: "notifications", label: "Notifications" },
  { value: "data", label: "Data quality", disabled: true },
];

export function Vertical() {
  return (
    <div className="grid" style={{ maxWidth: "36rem" }}>
      <Tabs aria-label="Settings, vertical" orientation="vertical" variant="underline" tabs={settings.slice(0, 2)}>
        <TabsPanel value="users">
          <p>Who can see and edit candidates.</p>
        </TabsPanel>
        <TabsPanel value="notifications">
          <p>Email and Slack alerts for new referrals.</p>
        </TabsPanel>
      </Tabs>
      <Tabs aria-label="Chip sections, vertical" orientation="vertical" tabs={sections} defaultValue="runs" />
    </div>
  );
}

Panels

TabsPanels inside Tabs are linked to their tabs and stay mounted while hidden: type a note, switch tabs, and come back. keepMounted={false} unmounts a hidden panel instead, for a heavy view.

import { InputArea } from "@etched/monet/input";
import { Tabs, TabsPanel } from "@etched/monet/tabs";

export function Panels() {
  return (
    <Tabs
      aria-label="Candidate workspace"
      variant="underline"
      tabs={[
        { value: "notes", label: "Notes" },
        { value: "activity", label: "Activity" },
      ]}
    >
      <TabsPanel value="notes" className="grid">
        <InputArea aria-label="Note" placeholder="Type a note, then switch tabs" rows={2} />
      </TabsPanel>
      <TabsPanel value="activity" keepMounted={false}>
        <p>Oct 14 · Moved to Interviewing by Sam Lee</p>
      </TabsPanel>
    </Tabs>
  );
}

Accessibility

  • ArrowLeft/ArrowRight (ArrowUp/ArrowDown when vertical) move focus and wrap; Home/End go to the ends. Enter or Space selects the focused tab.
  • With activateOnFocus, the arrow keys select as they move. A disabled tab has aria-disabled and cannot be selected.
  • A tablist named by aria-label, with tabs that have aria-selected. Each TabsPanel is a tabpanel named by its tab.
  • The scroll buttons at the ends are aria-hidden and take no focus; the arrow keys reach every tab.
  • TabsNav is a nav of links, named by its required aria-label, with aria-current="page" on the current link. It has no tab roles.
  • Give Tabs an aria-label. Use TabsNav when each tab opens its own URL.

API reference

TabsNav

PropTypeDefaultDescription
links *TabsLink[]–
aria-label *string–Names the navigation, for example "Settings sections".
variant"segmented" | "underline""segmented"
size"base" | "sm""base"

TabsPanel

PropTypeDefaultDescription
value *string–The value of its tab.
children *ReactNode–
keepMountedbooleantrueKeeps the panel mounted while hidden, so what it holds (typed text, scroll, loaded data) is kept when the user comes back. false unmounts it, for a heavy view that should not stay in the page.

Tabs

PropTypeDefaultDescription
tabs *TabsItem[]–
valuestring–Selected tab, controlled.
defaultValuestringthe first tabFirst selected tab, uncontrolled.
onValueChange(value: string) => void–
activateOnFocusbooleanfalseSelect a tab when the arrow keys move focus to it. Otherwise Enter or Space selects it.
variant"segmented" | "underline""segmented"
size"base" | "sm""base"
orientation"horizontal" | "vertical""horizontal"A row of tabs, or a column beside the panels.
aria-labelstring–Names the tab list, for example "Role views".
childrenReactNode–TabsPanels, one per tab. Without them, the app renders the selected view itself.