Layout
Scroll Area
A scroll container that looks like it belongs: thin, token-coloured bars that thicken under the pointer, and optional edge fades that say there is more beyond an edge — and only when there is.
import { ScrollArea } from 'omaris' Examples
Basic
maxHeight is as tall as it grows before it scrolls. The bar is a hairline until the pointer is on it, and the scrolling is the browser's own — momentum and all.
<script lang="ts"> import { Avatar, ScrollArea, Text } from 'omaris'; const PEOPLE = [ ['Amina Yusuf', 'Design'], ['Dilan Karim', 'Engineering'], ['Hevi Salih', 'Support'], ['Omar Aziz', 'Engineering'], ['Rozh Ahmed', 'Sales'], ['Sara Hama', 'Design'], ['Zana Omer', 'Operations'], ['Lana Faraj', 'Finance'], ['Karwan Ali', 'Engineering'], ['Shilan Rashid', 'Support'] ] as const;</script><ScrollArea maxHeight={240} label="Team" class="w-80 max-w-full rounded-shape-lg border border-border"> <ul class="flex flex-col py-1"> {#each PEOPLE as [name, team] (name)} <li class="flex items-center gap-3 px-4 py-2"> <Avatar {name} size="sm" colorize /> <Text variant="body-md" class="min-w-0 flex-1" lines={1}>{name}</Text> <Text variant="label-sm" tone="muted">{team}</Text> </li> {/each} </ul></ScrollArea> Edge fades
fade masks each edge the content has been scrolled away from, and only that edge — scroll down and the top fades in; reach the end and the bottom comes back sharp. A mask, so it works on any surface.
<script lang="ts"> import { ScrollArea, Text } from 'omaris'; const EVENTS = [ ['9:12 AM', 'Order #4821 confirmed on WhatsApp'], ['9:30 AM', 'Menu updated — 3 prices changed'], ['10:02 AM', 'Courier picked up #4821'], ['10:15 AM', 'New review: five stars'], ['10:41 AM', 'Order #4822 confirmed on WhatsApp'], ['11:05 AM', 'Kahi with geymar marked sold out'], ['11:20 AM', 'Order #4823 cancelled by the customer'], ['11:48 AM', 'Courier picked up #4822'], ['12:10 PM', 'Lunch menu switched on'], ['12:31 PM', 'Order #4824 confirmed on WhatsApp'] ] as const;</script><ScrollArea maxHeight="15rem" fade label="Today's activity" class="w-80 max-w-full rounded-shape-lg bg-surface-container-low"> <ol class="flex flex-col gap-3 p-4"> {#each EVENTS as [time, text] (time)} <li class="flex gap-3"> <Text variant="label-md" tone="muted" tabular class="w-16 shrink-0">{time}</Text> <Text variant="body-sm">{text}</Text> </li> {/each} </ol></ScrollArea> Horizontal
orientation="horizontal" for a row that runs off the side, and both for a table bigger than its frame — sticky headers work, because the viewport is the scroller. The fades follow the reading direction, and the scrollbar lanes are kept out of the mask.
<script lang="ts"> import { Chip, ScrollArea, Text } from 'omaris'; const CATEGORIES = [ 'Grills', 'Breakfast', 'Sandwiches', 'Rice dishes', 'Soups', 'Salads', 'Desserts', 'Juices', 'Hot drinks' ]; const DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']; const HOURS = Array.from({ length: 12 }, (_, i) => `${i + 10}:00`); let chosen = $state(Object.fromEntries(CATEGORIES.map((c) => [c, c === 'Grills'])));</script><div class="flex w-full max-w-md flex-col gap-6"> <ScrollArea orientation="horizontal" fade label="Categories"> <div class="flex gap-2 pb-2"> {#each CATEGORIES as name (name)} <Chip selectable bind:selected={chosen[name]}>{name}</Chip> {/each} </div> </ScrollArea> <ScrollArea orientation="both" fade maxHeight="12rem" label="Orders by hour" class="rounded-shape-lg border border-border" > <table class="border-separate border-spacing-0"> <thead> <tr> <td class="sticky start-0 top-0 z-20 bg-surface"></td> {#each HOURS as hour (hour)} <th scope="col" class="sticky top-0 z-10 bg-surface px-3 py-2"> <Text variant="label-sm" tone="muted" tabular>{hour}</Text> </th> {/each} </tr> </thead> <tbody> {#each DAYS as day, d (day)} <tr> <th scope="row" class="sticky start-0 z-10 bg-surface px-3 py-2 text-start"> <Text variant="label-md">{day}</Text> </th> {#each HOURS as hour, h (hour)} <td class="px-3 py-2 text-center"> <Text variant="body-sm" tabular>{(d * 7 + h * 5) % 23}</Text> </td> {/each} </tr> {/each} </tbody> </table> </ScrollArea></div> Overridden
It ships no height, so class="h-56" is the height — no fight with a default. classes reaches the viewport and the content, and the root's data-overflow-top lifts a sticky header once the list has moved under it.
<script lang="ts"> import { Badge, ScrollArea, Text } from 'omaris'; const ITEMS = [ ['Chicken tikka', '9,000'], ['Kubba Mosul', '7,500'], ['Dolma', '8,000'], ['Masgouf', '22,000'], ['Tepsi baytinjan', '10,000'], ['Kahi with geymar', '4,000'], ['Lentil soup', '3,000'], ['Fattoush', '5,000'], ['Klecha', '2,500'] ] as const;</script><ScrollArea aria-label="Menu" class="group/menu h-56 w-72 max-w-full rounded-shape-2xl bg-surface-container" classes={{ viewport: 'scroll-pt-12', content: 'pb-2' }}> <div class="sticky top-0 z-10 flex items-center justify-between bg-surface-container px-4 py-3 transition-shadow duration-200 ease-standard group-data-overflow-top/menu:shadow-1 motion-reduce:transition-none" > <Text variant="title-sm">Menu</Text> <Badge size="sm" variant="tonal">{ITEMS.length}</Badge> </div> {#each ITEMS as [name, price] (name)} <div class="flex items-center justify-between gap-3 px-4 py-2"> <Text variant="body-md" lines={1}>{name}</Text> <Text variant="body-sm" tone="muted" tabular>{price} IQD</Text> </div> {/each}</ScrollArea> When to use it
Use it for
- A panel inside the page that scrolls on its own — a member list in a card, an activity feed beside a chart, the body of a settings dialog.
- A row that runs off the side on a phone: category chips, a strip of cards, a week of days.
orientation="horizontal"keeps the page itself from scrolling sideways. - A table or grid wider and taller than its frame:
orientation="both", with sticky headers inside — the viewport is the scroller, sostickyworks. - A list where "there is more" should be visible:
fademasks each edge the content has left, and only while it has left it.
Not for
- The page's own scroll → nothing. The window already scrolls natively; an App Shell gives the main column its own.
- A long list of thousands of rows → a virtualised list, such as Log Viewer for logs. A scroll area renders every row it holds.
- Content that pages rather than scrolls → Carousel for slides, Pagination for results.
- A data table → Table, which already scrolls sideways on its own and pins its header with
sticky.
Do
- Size it from outside:
maxHeightto grow up to a limit, orclass="h-64"(orflex-1 min-h-0in a flex column) to fill a fixed space. It ships no height of its own, so either replaces nothing. - Give it a
label— "Team", "Order items". A scroll area that overflows takes focus so the keyboard can scroll it, and the label is what a screen reader calls that stop. - Use
fadeon a surface with no border to carry the edge, and a border or adata-overflow-top:shadow when the edge needs to be drawn. - Let the content wrap on the axis that does not scroll —
orientation="vertical"clips anything wider than the frame.
Don't
- Nest one scroll area in another on the same axis. A finger cannot tell which one it is moving.
- Put the only copy of a primary action at the bottom of a scrolled list; pin it outside the area instead.
- Use it to hide the scrollbar. Pass
classes={{ viewport: 'scrollbar-none' }}only where something else says the content moves — a fade, or a peek of the next card. - Wrap a whole screen in one — a phone's toolbar collapses and its pull-to-refresh works only on the page's own scroll.
Quick reference
orientation vertical(default)horizontalboth
API
ScrollArea
A scroll container that looks like it belongs: thin, token-coloured bars that thicken under the pointer, and optional edge fades that say there is more beyond an edge — and only when there is.
It is native scroll, nothing else. The browser moves the content, so a trackpad, a wheel, a flick with iOS momentum, the keyboard and scrollIntoView all behave exactly as they do everywhere else. No JavaScript scrolls anything. What the component adds is read-only: a passive scroll listener, throttled to one read per animation frame, that notes which edges the content has moved away from.
The fades are a mask, not a gradient painted over the content, so they work on any background — a card, an image, a tinted surface — without being told its colour. Each edge fades in as the content leaves it and out as it comes back, and the scrollbar lanes are kept out of the mask so the bar itself is never faded.
It is reachable. A scroll area whose content overflows takes focus (tabindex="0"), so a keyboard can scroll it with the arrows and Page keys; with a label it is a named region as well. An area that fits its content takes nothing and says nothing.
Size it from the outside — maxHeight, or a height class on the root. It ships no height of its own, so class="h-64" or max-h-[50dvh] replaces nothing and needs no !.
The edges it has left are on the root as data-overflow-top, -bottom, -start and -end, for a shadow or a divider of your own: data-overflow-top:shadow-1.
import { ScrollArea } from 'omaris' <ScrollArea maxHeight="20rem" fade label="Activity"> {#each events as e (e.id)}<ActivityRow {e} />{/each}</ScrollArea> Parts
Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.
root- The frame. It does not scroll: it carries the size, the shape and the focus ring, so the ring is never faded by the viewport's mask.
viewport- The element that scrolls. Rounded like its frame, so content clips at the corners.
content- Wraps the children; its size is what the area measures against.
Props
orientation Defaults to 'vertical'
ScrollAreaOrientation Which way it scrolls. The other axis is clipped.
verticalhorizontal- A row that runs off the side. The content is as wide as it wants to be.
both
maxHeight number | string The tallest it grows before it scrolls. A number is px, a string any CSS length — 320, "20rem", "50dvh". Unset, it grows with its content unless something outside gives it a height.
Set as a custom property rather than a class, so a max-h-* or h-* in class replaces it outright.
fade Defaults to false
boolean | number | string Fade the content out at each edge it has been scrolled away from. true is a 1.5rem fade; a number (px) or a CSS length sets the depth.
label string Names the area for a screen reader — "Activity", "Order items". When the content overflows the area takes focus, and with a label it is a region too. aria-label works the same.
ref bindableDefaults to null
HTMLDivElement | null The scrolling element, for scrollTo and friends. Bindable.
class string classes ScrollAreaClasses Per-part Tailwind overrides. class still covers the root.
children Snippet