Kosmesis
Components

Scroll Area

A custom-styled scroll container, composing Morphos's ScrollArea + Viewport + Scrollbar + Thumb.

Installation

npx kosmesis add scroll-area
pnpm dlx kosmesis add scroll-area
yarn dlx kosmesis add scroll-area
bunx kosmesis add scroll-area

Install the following dependencies:

npm install @morphos/layout
pnpm add @morphos/layout
yarn add @morphos/layout
bun add @morphos/layout

Copy and paste the following code into your project.

scroll-area.tsx
import { StatelessComponent } from "@praxisjs/core";import { Component } from "@praxisjs/decorators";import {  ScrollArea as MorphosScrollArea,  ScrollAreaScrollbar as MorphosScrollAreaScrollbar,  ScrollAreaThumb as MorphosScrollAreaThumb,  ScrollAreaViewport as MorphosScrollAreaViewport, type ScrollAreaScrollbarProps as MorphosScrollAreaScrollbarProps} from "@morphos/layout";import { cn } from "@/lib/utils";/** * Extends (not wraps) Morphos's `ScrollArea` so `this` can be passed directly as the `scrollArea` * prop `ScrollAreaViewport`/`ScrollAreaScrollbar`/`ScrollAreaThumb` all require — composing all * four parts into the single-component surface shadcn/ui's `ScrollArea` has, since consumers of * the raw Morphos primitive normally assemble those parts by hand (see its docs page). */@Component()export class ScrollArea extends MorphosScrollArea {  render() {    return (      <div id={this.id} class={cn("relative overflow-hidden", this.class)} data-type={this.type}>        {/*          `[scrollbar-width:none]`/`[&::-webkit-scrollbar]:hidden`: `MorphosScrollAreaViewport` sets          plain `overflow: auto` with no scrollbar-hiding of its own (unlike Radix's real Viewport,          which hides the native scrollbar by default since it renders its own Thumb/Scrollbar UI)          — without this, the browser's native scrollbar renders right on top of the custom one          below, in the same space, looking like a corrupted double scrollbar.        */}        <MorphosScrollAreaViewport          scrollArea={this}          class="size-full [scrollbar-width:none] [&::-webkit-scrollbar]:hidden rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"        >          {this.children}        </MorphosScrollAreaViewport>        <ScrollBar scrollArea={this} />        <ScrollBar scrollArea={this} orientation="horizontal" />      </div>    );  }}export type ScrollBarProps = MorphosScrollAreaScrollbarProps;/** * Morphos's `ScrollArea` doesn't auto-hide either scrollbar on its own — only a combined * `data-scrollable` (either axis) is exposed on the root, not per axis — so each bar hides itself * here based on its own axis's `canScrollX`/`canScrollY`, or an always-rendered, always-visible * horizontal track would sit on top of content even when nothing overflows horizontally. * * `MorphosScrollAreaScrollbar` applies no positioning of its own, so the track is `absolute` * here — otherwise it renders as a normal in-flow sibling below the viewport instead of overlaying * it. `MorphosScrollAreaThumb` likewise applies no sizing of its own: it exposes size/position as * the `--morphos-thumb-size`/`--morphos-thumb-offset` custom properties (unlike Radix's real * thumb, which sets `width`/`transform` inline via its own JS), so the thumb has to consume them * explicitly via `position: absolute` — `flex-1` (upstream shadcn/ui's class, meaningless here) * just stretches it to fill and hide the whole track. */@Component()export class ScrollBar extends StatelessComponent<ScrollBarProps> {  render() {    const { class: cls, orientation = "vertical", scrollArea, ...rest } = this.props;    const isHorizontal = orientation === "horizontal";    const canScroll = () => (isHorizontal ? scrollArea.canScrollX : scrollArea.canScrollY);    return (      <MorphosScrollAreaScrollbar        scrollArea={scrollArea}        orientation={orientation}        class={() =>          cn(            "absolute touch-none select-none",            !canScroll() && "hidden",            isHorizontal ? "inset-x-2 bottom-0.5 h-2.5" : "inset-y-2 right-0.5 w-2.5",            cls,          )        }        {...rest}      >        <MorphosScrollAreaThumb          scrollArea={scrollArea}          orientation={orientation}          class={            isHorizontal              ? "absolute inset-y-0 left-[var(--morphos-thumb-offset)] w-[var(--morphos-thumb-size)] rounded-full bg-border"              : "absolute inset-x-0 top-[var(--morphos-thumb-offset)] h-[var(--morphos-thumb-size)] rounded-full bg-border"          }        />      </MorphosScrollAreaScrollbar>    );  }}

Install the following dependencies:

npm install @morphos/layout @praxisjs/css
pnpm add @morphos/layout @praxisjs/css
yarn add @morphos/layout @praxisjs/css
bun add @morphos/layout @praxisjs/css

Copy and paste the following code into your project.

scroll-area.tsx
import { StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component } from "@praxisjs/decorators";import {  ScrollArea as MorphosScrollArea,  ScrollAreaScrollbar as MorphosScrollAreaScrollbar,  ScrollAreaThumb as MorphosScrollAreaThumb,  ScrollAreaViewport as MorphosScrollAreaViewport, type ScrollAreaScrollbarProps as MorphosScrollAreaScrollbarProps} from "@morphos/layout";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);/** * `MorphosScrollAreaScrollbar` applies no positioning of its own, so the track is `absolute` here * — otherwise it renders as a normal in-flow sibling below the viewport instead of overlaying it. * `MorphosScrollAreaThumb` likewise applies no sizing of its own: it exposes size/position as the * `--morphos-thumb-size`/`--morphos-thumb-offset` custom properties (unlike Radix's real thumb, * which sets `width`/`transform` inline via its own JS), so the thumb has to consume them * explicitly via `position: absolute`. */class ScrollAreaStyles extends Stylesheet {  $root = this.css({ position: "relative", overflow: "hidden" });  // `scrollbarWidth: "none"` / `&::-webkit-scrollbar`: `MorphosScrollAreaViewport` sets plain  // `overflow: auto` with no scrollbar-hiding of its own (unlike Radix's real Viewport, which  // hides the native scrollbar by default since it renders its own Thumb/Scrollbar UI) — without  // this, the browser's native scrollbar renders right on top of the custom one below, in the same  // space, looking like a corrupted double scrollbar.  $viewport = this.css({    width: "100%",    height: "100%",    borderRadius: "inherit",    outline: "none",    scrollbarWidth: "none",  })    .focusVisible({ boxShadow: `0 0 0 3px color-mix(in oklab, ${t.ring} 50%, transparent)` })    .on("&::-webkit-scrollbar", { display: "none" });  $scrollbarVertical = this.css({    position: "absolute",    top: "0.5rem",    bottom: "0.5rem",    right: "0.125rem",    width: "0.625rem",    touchAction: "none",    userSelect: "none",  });  $scrollbarHorizontal = this.css({    position: "absolute",    left: "0.5rem",    right: "0.5rem",    bottom: "0.125rem",    height: "0.625rem",    touchAction: "none",    userSelect: "none",  });  $hidden = this.css({ display: "none" });  $thumbVertical = this.css({    position: "absolute",    left: "0",    right: "0",    top: "var(--morphos-thumb-offset)",    height: "var(--morphos-thumb-size)",    borderRadius: "9999px",    backgroundColor: t.border,  });  $thumbHorizontal = this.css({    position: "absolute",    top: "0",    bottom: "0",    left: "var(--morphos-thumb-offset)",    width: "var(--morphos-thumb-size)",    borderRadius: "9999px",    backgroundColor: t.border,  });}/** * Extends (not wraps) Morphos's `ScrollArea` so `this` can be passed directly as the `scrollArea` * prop `ScrollAreaViewport`/`ScrollAreaScrollbar`/`ScrollAreaThumb` all require — composing all * four parts into the single-component surface shadcn/ui's `ScrollArea` has, since consumers of * the raw Morphos primitive normally assemble those parts by hand (see its docs page). */@Component()export class ScrollArea extends MorphosScrollArea {  @Styled(ScrollAreaStyles) $s!: ScrollAreaStyles;  render() {    return (      <div id={this.id} class={cx(this.$s.$root, this.class)} data-type={this.type}>        <MorphosScrollAreaViewport scrollArea={this} class={this.$s.$viewport}>          {this.children}        </MorphosScrollAreaViewport>        <ScrollBar scrollArea={this} />        <ScrollBar scrollArea={this} orientation="horizontal" />      </div>    );  }}export type ScrollBarProps = MorphosScrollAreaScrollbarProps;/** * Morphos's `ScrollArea` doesn't auto-hide either scrollbar on its own — only a combined * `data-scrollable` (either axis) is exposed on the root, not per axis — so each bar hides itself * here based on its own axis's `canScrollX`/`canScrollY`, or an always-rendered, always-visible * horizontal track would sit on top of content even when nothing overflows horizontally. */@Component()export class ScrollBar extends StatelessComponent<ScrollBarProps> {  @Styled(ScrollAreaStyles) $s!: ScrollAreaStyles;  render() {    const { class: cls, orientation = "vertical", scrollArea, ...rest } = this.props;    const isHorizontal = orientation === "horizontal";    const canScroll = () => (isHorizontal ? scrollArea.canScrollX : scrollArea.canScrollY);    return (      <MorphosScrollAreaScrollbar        scrollArea={scrollArea}        orientation={orientation}        class={() =>          cx(            isHorizontal ? this.$s.$scrollbarHorizontal : this.$s.$scrollbarVertical,            !canScroll() && this.$s.$hidden,            cls,          )        }        {...rest}      >        <MorphosScrollAreaThumb          scrollArea={scrollArea}          orientation={orientation}          class={isHorizontal ? this.$s.$thumbHorizontal : this.$s.$thumbVertical}        />      </MorphosScrollAreaScrollbar>    );  }}

Examples

Usage

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

<ScrollArea class="h-72 w-48 rounded-md border">
  <div class="p-4">
    {Array.from({ length: 50 }, (_, i) => (
      <p key={i} class="text-sm">Item {i + 1}</p>
    ))}
  </div>
</ScrollArea>

Unlike Dialog/Tabs/Accordion, ScrollArea doesn't need a second, separately-tracked instance — it composes Viewport/Scrollbar/Thumb internally and passes this to them directly, so new ScrollArea() is the only instance you ever need.

On this page