Monet
@etched/monet 0.0.1

Resizable

Ported

Panels with a handle between them that changes their sizes, for master–detail views: a list beside the record it opens, or a trace above its logs. The app sets the group's size with className; a vertical group needs a height.

List

Detail

import { type CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@etched/monet/resizable";

/** A panel edge around the group, like Orbit's cards. The app sets the size. */
const frame = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)", overflow: "clip" });

const pad: CSSProperties = { display: "grid", alignContent: "start", gap: "var(--et-space-1)", padding: "var(--et-space-3) var(--et-space-4)" };

export function Preview() {
  return (
    <ResizablePanelGroup style={frame({ inlineSize: "18rem", blockSize: "7rem" })}>
      <ResizablePanel defaultSize={40} minSize={25} style={pad}>
        <Text size="sm" variant="secondary">
          List
        </Text>
      </ResizablePanel>
      <ResizableHandle aria-label="List width" />
      <ResizablePanel defaultSize={60} style={pad}>
        <Text size="sm" variant="secondary">
          Detail
        </Text>
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

Installation

import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@etched/monet/resizable";

Examples

List and detail

Drag the line, or focus it and use Left and Right (16 px a step). The list stays between 25% and 60% of the width; Home and End move it to those limits, and a double-click puts it back to 35%. The detail panel takes the open candidate as its key, so each one opens fresh.

Ana RuizASIC design engineer
José NúñezFirmware engineer
Mira OseiPhysical design lead
Ana Ruiz

ASIC design engineer

import { useState, type CSSProperties } from "react";
import { SelectableList, type SelectableListItem } from "@etched/monet/selectable-list";
import { Text } from "@etched/monet/text";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@etched/monet/resizable";

/** A panel edge around the group, like Orbit's cards. The app sets the size. */
const frame = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)", overflow: "clip" });

const pad: CSSProperties = { display: "grid", alignContent: "start", gap: "var(--et-space-1)", padding: "var(--et-space-3) var(--et-space-4)" };

const people: SelectableListItem[] = [
  { value: "ana", label: "Ana Ruiz", description: "ASIC design engineer", avatar: { name: "Ana Ruiz" } },
  { value: "jose", label: "José Núñez", description: "Firmware engineer", avatar: { name: "José Núñez" } },
  { value: "mira", label: "Mira Osei", description: "Physical design lead", avatar: { name: "Mira Osei" } },
];

export function ListAndDetail() {
  const [open, setOpen] = useState<string | null>("ana");
  const person = people.find((p) => p.value === open);

  return (
    <ResizablePanelGroup style={frame({ blockSize: "14rem", maxInlineSize: "40rem" })}>
      <ResizablePanel defaultSize={35} minSize={25} maxSize={60}>
        <SelectableList aria-label="Candidates" items={people} value={open} onValueChange={setOpen} />
      </ResizablePanel>
      <ResizableHandle aria-label="Candidate list width" />
      <ResizablePanel key={open} defaultSize={65} style={pad}>
        <Text variant="heading">{person?.label}</Text>
        <Text variant="secondary">{person?.description}</Text>
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

One above the other

With orientation="vertical", the handle moves up and down (Up and Down keys). A vertical group needs a height.

Trace

Run 1,284 · 3 steps · 42 s

Logs

12 lines

import { type CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@etched/monet/resizable";

/** A panel edge around the group, like Orbit's cards. The app sets the size. */
const frame = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)", overflow: "clip" });

const pad: CSSProperties = { display: "grid", alignContent: "start", gap: "var(--et-space-1)", padding: "var(--et-space-3) var(--et-space-4)" };

export function Vertical() {
  return (
    <ResizablePanelGroup orientation="vertical" style={frame({ blockSize: "14rem", maxInlineSize: "40rem" })}>
      <ResizablePanel defaultSize={60} minSize={30} style={pad}>
        <Text variant="heading">Trace</Text>
        <Text size="sm" variant="secondary">
          Run 1,284 · 3 steps · 42 s
        </Text>
      </ResizablePanel>
      <ResizableHandle aria-label="Trace height" />
      <ResizablePanel defaultSize={40} minSize={20} style={pad}>
        <Text variant="heading">Logs</Text>
        <Text size="sm" variant="secondary">
          12 lines
        </Text>
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

Three panels

Each handle changes the two panels next to it, within both panels' limits.

Roles

Candidates

Profile

import { type CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@etched/monet/resizable";

/** A panel edge around the group, like Orbit's cards. The app sets the size. */
const frame = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)", overflow: "clip" });

const pad: CSSProperties = { display: "grid", alignContent: "start", gap: "var(--et-space-1)", padding: "var(--et-space-3) var(--et-space-4)" };

export function ThreePanels() {
  return (
    <ResizablePanelGroup style={frame({ blockSize: "8rem", maxInlineSize: "40rem" })}>
      <ResizablePanel defaultSize={25} minSize={15} style={pad}>
        <Text size="sm" variant="secondary">
          Roles
        </Text>
      </ResizablePanel>
      <ResizableHandle aria-label="Roles width" />
      <ResizablePanel defaultSize={40} minSize={25} style={pad}>
        <Text size="sm" variant="secondary">
          Candidates
        </Text>
      </ResizablePanel>
      <ResizableHandle aria-label="Candidates width" />
      <ResizablePanel defaultSize={35} minSize={25} style={pad}>
        <Text size="sm" variant="secondary">
          Profile
        </Text>
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

Accessibility

  • ArrowLeft/ArrowRight move a focused handle 16 px a step (ArrowUp/ArrowDown in a vertical group). Home/End move it to its limits.
  • Each handle is a focusable separator, named by its required aria-label. Its aria-controls points at the panel before it.
  • Screen readers hear that panel's size in percent, from aria-valuenow, aria-valuemin and aria-valuemax.
  • Name each handle for what it sizes, such as "Candidate list width".

API reference

ResizableHandle

PropTypeDefaultDescription
aria-label *string–Names the handle, for example "Candidate list width".

ResizablePanelGroup

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"
children *ReactNode–ResizablePanels with a ResizableHandle between each two.

ResizablePanel

PropTypeDefaultDescription
defaultSize *number–Its first size, in percent of the group. The sizes of a group's panels add up to 100.
minSizenumber0The smallest size a handle can give it, in percent.
maxSizenumber100The largest size a handle can give it, in percent.
childrenReactNode–