# Scroll Area

A scroll container with propel's overlay scrollbar that reveals on hover or scroll, built on Base UI ScrollArea.

## Installation

```tsx
import { ScrollArea } from "@makeplane/propel/components/scroll-area";
```
## Usage

```tsx
import { ScrollArea } from "@makeplane/propel/components/scroll-area";

const ACTIVITY = Array.from(
  { length: 24 },
  (_, i) => `Astra updated the project roadmap — item ${i + 1}`,
);

export default function BasicDemo() {
  // ScrollArea fills its parent, so it needs a height-constrained flex column to
  // bound and scroll within. Inline styles keep the demo self-contained.
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "16rem", width: "18rem" }}>
      <ScrollArea orientation="vertical">
        <div
          style={{ display: "flex", flexDirection: "column", gap: "0.5rem", padding: "0.75rem" }}
        >
          {ACTIVITY.map((line) => (
            <p key={line}>{line}</p>
          ))}
        </div>
      </ScrollArea>
    </div>
  );
}
```

`ScrollArea` fills its parent (`h-full w-full`): place it in a flex column or any parent with a definite height (or a `max-h` cap) and the viewport scrolls once the content overflows.

Children render inside the viewport. Base UI’s optional `ScrollArea.Content` wrapper is not part of the ready-made — add it yourself only if you need that slot.

## Examples

### Horizontal

```tsx
import { ScrollArea } from "@makeplane/propel/components/scroll-area";

const MEMBERS = Array.from({ length: 16 }, (_, i) => `Teammate ${i + 1}`);

export default function HorizontalDemo() {
  // Horizontal overflow only: a single horizontal scrollbar, shown on demand. The
  // content is a `w-max` row so it overflows sideways within the fixed-width parent.
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "9rem", width: "18rem" }}>
      <ScrollArea orientation="horizontal">
        <div style={{ display: "flex", width: "max-content", gap: "0.75rem", padding: "0.75rem" }}>
          {MEMBERS.map((member) => (
            <div
              key={member}
              style={{
                display: "flex",
                height: "4rem",
                width: "9rem",
                flexShrink: 0,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: "0.375rem",
                background: "var(--color-layer-2)",
              }}
            >
              {member}
            </div>
          ))}
        </div>
      </ScrollArea>
    </div>
  );
}
```

### Both axes

```tsx
import { ScrollArea } from "@makeplane/propel/components/scroll-area";

const LINES = Array.from(
  { length: 30 },
  (_, i) => `Line ${i + 1} of wide content that overflows horizontally as well as down.`,
);

export default function BothAxesDemo() {
  // Both axes overflow: a vertical and a horizontal scrollbar, each shown on demand,
  // plus the corner where they meet. The content is wider and taller than the parent.
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "16rem", width: "18rem" }}>
      <ScrollArea orientation="both">
        <div
          style={{
            display: "flex",
            width: "40rem",
            flexDirection: "column",
            gap: "0.5rem",
            padding: "0.75rem",
          }}
        >
          {LINES.map((line) => (
            <p key={line} style={{ whiteSpace: "nowrap" }}>
              {line}
            </p>
          ))}
        </div>
      </ScrollArea>
    </div>
  );
}
```

### Inset

`variant="inset"` reserves the scrollbar track as its own lane on each rendered axis (like `scrollbar-gutter: stable`), so the thumb never covers content and the content never shifts when it starts or stops overflowing. The lane is reserved even when nothing overflows. The default `overlay` floats the thumb over the content's edge.

```tsx
import { ScrollArea, type ScrollAreaVariant } from "@makeplane/propel/components/scroll-area";

const VARIANTS: ScrollAreaVariant[] = ["overlay", "inset"];

const ROWS = Array.from({ length: 30 }, (_, i) => ({
  label: `Row ${i + 1}`,
  value: (i + 1) * 128,
}));

export default function InsetDemo() {
  // Rows run to the edge (striped fill, right-aligned value). With `overlay` the thumb sits on top
  // of them; `inset` reserves the track as its own lane, so the thumb never covers content and the
  // content never shifts when it starts or stops overflowing.
  return (
    <div style={{ display: "flex", gap: "0.75rem" }}>
      {VARIANTS.map((variant) => (
        <div key={variant} style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
          <p>{variant}</p>
          <div
            style={{ display: "flex", flexDirection: "column", height: "14rem", width: "12rem" }}
          >
            <ScrollArea orientation="vertical" visibility="always" size="lg" variant={variant}>
              {ROWS.map(({ label, value }, i) => (
                <div
                  key={label}
                  style={{
                    display: "flex",
                    justifyContent: "space-between",
                    padding: "0.375rem 0.75rem",
                    background: i % 2 ? "var(--bg-layer-1)" : undefined,
                  }}
                >
                  <span>{label}</span>
                  <span>{value}</span>
                </div>
              ))}
            </ScrollArea>
          </div>
        </div>
      ))}
    </div>
  );
}
```

### Always visible

```tsx
import { ScrollArea } from "@makeplane/propel/components/scroll-area";

const LINES = Array.from({ length: 30 }, (_, i) => `Line ${i + 1} of the scrollable content.`);

export default function AlwaysVisibleDemo() {
  // `visibility="always"` keeps the scrollbar permanently visible instead of revealing
  // it on hover/scroll — useful for embedded editors or data tables where users expect
  // a persistent rail. Paired here with the roomier `lg` gutter.
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "16rem", width: "18rem" }}>
      <ScrollArea orientation="vertical" visibility="always" size="lg">
        <div
          style={{ display: "flex", flexDirection: "column", gap: "0.5rem", padding: "0.75rem" }}
        >
          {LINES.map((line) => (
            <p key={line}>{line}</p>
          ))}
        </div>
      </ScrollArea>
    </div>
  );
}
```

### Sizes

The `size` prop sets the scrollbar gutter (same pad as native `scrollbar-*`): `sm` (8 px / 2 → 4 px thumb), `md` (10 / 2 → 6, the default), `lg` (12 / 2 → 8).

```tsx
import { ScrollArea } from "@makeplane/propel/components/scroll-area";

const SIZES = ["sm", "md", "lg"] as const;

const LINES = Array.from({ length: 30 }, (_, i) => `Line ${i + 1} of the scrollable content.`);

export default function SizesDemo() {
  // All three scrollbar gutter steps: `sm` — 8 / 2 → 4 px thumb; `md` — 10 / 2 → 6;
  // `lg` — 12 / 2 → 8. Shown with `visibility="always"` so rails are visible at rest.
  return (
    <div style={{ display: "flex", gap: "0.75rem" }}>
      {SIZES.map((size) => (
        <div key={size} style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
          <p>{size}</p>
          <div
            style={{ display: "flex", flexDirection: "column", height: "14rem", width: "12rem" }}
          >
            <ScrollArea orientation="vertical" visibility="always" size={size}>
              <div
                style={{
                  display: "flex",
                  flexDirection: "column",
                  gap: "0.5rem",
                  padding: "0.75rem",
                }}
              >
                {LINES.map((line) => (
                  <p key={line}>{line}</p>
                ))}
              </div>
            </ScrollArea>
          </div>
        </div>
      ))}
    </div>
  );
}
```
## Native scrollbars

Propel also ships **opt-in** utilities that re-skin the browser’s own scrollbar: `scrollbar-sm`, `scrollbar-md`, and `scrollbar-lg` (from `@makeplane/propel/styles`). Put the class on any overflow element — no `ScrollArea` required.

| Utility        | Track | Inset | Visible thumb |
| -------------- | ----- | ----- | ------------- |
| `scrollbar-sm` | 8px   | 2px   | 4px           |
| `scrollbar-md` | 10px  | 2px   | 6px           |
| `scrollbar-lg` | 12px  | 2px   | 8px           |

Same size ladder as `ScrollArea`’s `size` prop. Thumb colors use `--scrollbar-thumb*` (hidden at rest, stronger on container hover / thumb hover / drag).

**Use native utilities when** you can't wrap the region in `ScrollArea` (a `textarea`, or a third-party element that owns its own overflow) and can accept browser limits (Firefox: thinner control; no custom inset / per-thumb states).

**Use `ScrollArea` for everything else**, so the thumb looks and behaves the same in every browser (including Firefox and touch), with controlled show/hide (`visibility`) and both-axis rails + corner. Pick the `variant` by what sits under the edge:

- **`inset`** for panes whose content runs to the edge: settings and admin pages, lists with full-bleed row fills or right-aligned values, anything that previously used a native `scrollbar-*` gutter.
- **`overlay`** (default) for compact floating surfaces (menus, popovers, comboboxes) where a reserved lane would waste width and the content already has padding.

```tsx
<div className="scrollbar-sm max-h-96 overflow-y-auto">{/* … */}</div>
```
## API Reference

### ScrollArea

A scroll container with propel's scrollbar, built on Base UI `ScrollArea`. Use it to wrap any
overflowing content (menus, panels, long lists). It fills its parent: inside a flex column it
grows to fill the column (`flex-1`), and inside any other parent with a definite height it takes
that height (`h-full`). Constrain the parent's height (or width), and the viewport scrolls when
the content overflows. `variant` picks whether the scrollbar overlays the content or keeps its
own lane.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` (required) | `"both" \| "horizontal" \| "vertical"` | — | Which axes scroll (required, no silent default). `vertical`/`horizontal` render a single scrollbar; `both` renders both plus the corner. Render only the axes the content can actually overflow so an unused scrollbar never reserves space or reveals. |
| `children` (required) | `ReactNode` | — | The scrollable content (rendered inside the viewport). |
| `variant` | `"overlay" \| "inset"` | `overlay` | How the scrollbar sits against the content. `overlay` floats it over the content's edge. `inset` always reserves its track (for each rendered axis), so the thumb never covers content and content never shifts when it starts or stops overflowing. |
| `visibility` | `"auto" \| "always"` | `auto` | When the scrollbar is shown. `auto` hides it at rest and reveals it on hover/scroll (fades when idle). `always` keeps it permanently visible. |
| `size` | `"sm" \| "md" \| "lg"` | `md` | Scrollbar gutter size (propel scrollbar scale). Stable full gutter; pad matches native `scrollbar-*`: `sm` — 8 px / 2 → 4 px thumb; `md` — 10 / 2 → 6; `lg` — 12 / 2 → 8. |
| `viewportRef` | `Ref<HTMLDivElement>` | — | Ref to the scrolling viewport, for scroll restoration or virtualized collections. |
