Propel

Scroll Area

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

Show code
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>
  );
}

Installation

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

Usage

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

Show code
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

Show code
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.

overlay

inset

Show code
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

Show code
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).

sm

md

lg

Show code
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.
<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.

PropTypeDefaultDescription
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"overlayHow 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"autoWhen 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"mdScrollbar 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.
viewportRefRef<HTMLDivElement>—Ref to the scrolling viewport, for scroll restoration or virtualized collections.