Monet
@etched/monet 0.0.1

Scroll area

Ported

A box that scrolls its content, with thin scrollbars that show while the pointer is over it or while it scrolls. The app sets its size with className: a height, or a max height that it grows to. Scrolling stays native: wheel, touch, keys and find in page. When the content overflows, the region takes focus, so the arrow keys scroll it.

import type { CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ScrollArea } from "@etched/monet/scroll-area";

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

const activity = [
  "Ana Ruiz moved to Interviewing",
  "Kai Ito left feedback on José Núñez",
  "Sam Lee scheduled an onsite for Mira Osei",
  "Lee Park declined the offer",
  "New referral: Priya Raman, from Kai Ito",
  "José Núñez moved to References check",
  "Mira Osei accepted the offer",
  "Sam Lee archived 4 candidates",
  "Ana Ruiz uploaded a new resume",
  "Kai Ito added a note to Priya Raman",
  "Weekly digest sent to 12 people",
  "Lee Park moved to We Passed",
];

function Activity({ count }: { count: number }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-2)", padding: "var(--et-space-3) var(--et-space-4)" }}>
      {activity.slice(0, count).map((line) => (
        <Text key={line} as="p" size="sm">
          {line}
        </Text>
      ))}
    </div>
  );
}

export function Preview() {
  return (
    <ScrollArea aria-label="Activity" style={box({ blockSize: "8rem", inlineSize: "16rem" })}>
      <Activity count={12} />
    </ScrollArea>
  );
}

Installation

import { ScrollArea } from "@etched/monet/scroll-area";

Examples

A fixed height

The thin scrollbar shows while the pointer is over the box or while it scrolls. The region takes focus, so the arrow keys scroll it.

import type { CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ScrollArea } from "@etched/monet/scroll-area";

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

const activity = [
  "Ana Ruiz moved to Interviewing",
  "Kai Ito left feedback on José Núñez",
  "Sam Lee scheduled an onsite for Mira Osei",
  "Lee Park declined the offer",
  "New referral: Priya Raman, from Kai Ito",
  "José Núñez moved to References check",
  "Mira Osei accepted the offer",
  "Sam Lee archived 4 candidates",
  "Ana Ruiz uploaded a new resume",
  "Kai Ito added a note to Priya Raman",
  "Weekly digest sent to 12 people",
  "Lee Park moved to We Passed",
];

function Activity({ count }: { count: number }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-2)", padding: "var(--et-space-3) var(--et-space-4)" }}>
      {activity.slice(0, count).map((line) => (
        <Text key={line} as="p" size="sm">
          {line}
        </Text>
      ))}
    </div>
  );
}

export function FixedHeight() {
  return (
    <ScrollArea aria-label="Recent activity" style={box({ blockSize: "12rem", maxInlineSize: "24rem" })}>
      <Activity count={12} />
    </ScrollArea>
  );
}

Up to a max height

With a max height, the box grows with its content and scrolls only past it. A box that does not scroll takes no focus and shows no scrollbar.

import type { CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ScrollArea } from "@etched/monet/scroll-area";

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

const activity = [
  "Ana Ruiz moved to Interviewing",
  "Kai Ito left feedback on José Núñez",
  "Sam Lee scheduled an onsite for Mira Osei",
  "Lee Park declined the offer",
  "New referral: Priya Raman, from Kai Ito",
  "José Núñez moved to References check",
  "Mira Osei accepted the offer",
  "Sam Lee archived 4 candidates",
  "Ana Ruiz uploaded a new resume",
  "Kai Ito added a note to Priya Raman",
  "Weekly digest sent to 12 people",
  "Lee Park moved to We Passed",
];

function Activity({ count }: { count: number }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-2)", padding: "var(--et-space-3) var(--et-space-4)" }}>
      {activity.slice(0, count).map((line) => (
        <Text key={line} as="p" size="sm">
          {line}
        </Text>
      ))}
    </div>
  );
}

export function MaxHeight() {
  return (
    <div className="row" style={{ alignItems: "flex-start" }}>
      <ScrollArea aria-label="Short activity" style={box({ maxBlockSize: "10rem", inlineSize: "16rem" })}>
        <Activity count={2} />
      </ScrollArea>
      <ScrollArea aria-label="Long activity" style={box({ maxBlockSize: "10rem", inlineSize: "16rem" })}>
        <Activity count={12} />
      </ScrollArea>
    </div>
  );
}

Wide content

Content wider than the box scrolls sideways, with a horizontal scrollbar.

import type { CSSProperties } from "react";
import { Pill, type PillTone } from "@etched/monet/pill";
import { ScrollArea } from "@etched/monet/scroll-area";

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

const stages: [string, PillTone][] = [
  ["To Do", "neutral"],
  ["Activating", "blue"],
  ["Scheduling", "orange"],
  ["Scheduled", "purple"],
  ["In Touch", "teal"],
  ["Interviewing", "cyan"],
  ["References check", "cyan"],
  ["No Response", "amber"],
  ["Revisit", "olive"],
  ["Hired", "green"],
  ["We Passed", "red"],
];

export function WideContent() {
  return (
    <ScrollArea aria-label="Stages" style={box({ maxInlineSize: "24rem" })}>
      <div className="row" style={{ flexWrap: "nowrap", padding: "var(--et-space-3)" }}>
        {stages.map(([stage, tone]) => (
          <Pill key={stage} tone={tone}>
            {stage}
          </Pill>
        ))}
      </div>
    </ScrollArea>
  );
}

Accessibility

  • When the content overflows, the box is in the Tab order. Then the arrow keys and Home/End scroll it, as the browser does.
  • The scrolling box gets aria-label or aria-labelledby, and a named box is a region. The scrollbars are aria-hidden, and a box that fits its content takes no focus.
  • Name it with aria-label, or with aria-labelledby on a heading, so the box has a name when it takes focus.

API reference

PropTypeDefaultDescription
children *ReactNode–
viewportRefRef<HTMLDivElement>–The element that scrolls, for scrollTo or a table of contents' root. ref is the outer box.
aria-labelstring–Names the box that scrolls, which is then a region; it takes focus when its content overflows. Set it (or aria-labelledby) when no heading names the box.