3 Scroller
L4stIdi0t edited this page 2026-09-14 21:13:23 +02:00

Scroller

A strip of content that scrolls, with a button at each end.

The scrollbar is hidden — the buttons are the affordance — and a horizontal strip moves only under a horizontal gesture: a trackpad swipe, a tilt wheel, a touch drag, the keyboard, or those buttons. A button is disabled at the edge it would scroll towards, so a strip that fits has both disabled and reads as "there is nothing more". The buttons float over the strip at every width. Where the device can hover they appear on hover or focus; where it cannot — a touchscreen — they stay shown, since a control you cannot discover is not one. The strip is padded by exactly the space they occupy, so the first and last item are never underneath one. showButtons={false} drops both buttons and that padding; every other way of scrolling stays. A press lands on an item boundary rather than a round number of pixels: whatever the reader is scrolling towards arrives whole, not half cut off. A plain vertical wheel is never taken. Browsers turn one into a sideways scroll over a horizontal box, which hijacks a page the reader was only passing through, so the strip hands that event back to the page and the mouse without a sideways gesture uses the buttons instead. onLoadMore makes it an endless one: it fires as the far end comes within loadThreshold, and again only once the content has actually grown — so a batch that adds nothing ends the run instead of asking forever.

Layout · keyboard contract

<script lang="ts">
	import { Scroller } from '@l4st/driftwood';
</script>
Light Dark
scroller scroller in dark mode

Props

Prop Type Default What it does
orientation 'horizontal' | 'vertical' 'horizontal' Which way it scrolls. A vertical one needs a height, from class.
step number — Pixels one button press scrolls. Defaults to just under a viewport.
yieldWheel boolean true Pass a vertical wheel over a horizontal strip on to the page, rather than moving the strip.
showButtons boolean true Render the ← / → (↑ / ↓) buttons. Off, the strip still scrolls by wheel, touch and keyboard, and loses the padding they stood in.
class string — Merged with the component's own classes, last — so yours win.
viewportClass string — Classes for the strip itself — where the gap and padding between items go.
aria-label string — Names the scrollable region, which is a tab stop. Required for a screen reader.
previousLabel string — Accessible name of the ← / ↑ button. Defaults to "Scroll left" / "Scroll up".
nextLabel string — Accessible name of the → / ↓ button. Defaults to "Scroll right" / "Scroll down".
onEdgeChange (edges: { start: boolean; end: boolean }) => void — Fires when the strip reaches, or leaves, either end.
onLoadMore () => void — Asks for the next batch, once per batch, as the far end approaches.
loadThreshold number 200 How near the end, in pixels, asks for more.
loading boolean false A batch is on its way: shows a spinner at the far end and asks for nothing more.
loadingLabel string 'Loading more' Announced beside the spinner while a batch loads. Localise here.
children required Snippet — The items, laid out in a flex row or column.

Keyboard

Key Does
← / → (↑ / ↓ when vertical) scroll one step, while the strip itself has focus
Home / End jump to either end

Example

The markup behind the screenshot above, lifted from src/routes/shots/ — so it compiles, and it is exactly what produced the image.

<Scroller aria-label="Volumes">
	{#each ['volume1', 'volume2', 'backups', 'archive', 'scratch', 'media'] as name (name)}
		<div class="bg-card w-32 shrink-0 rounded-lg border p-3">
			<p class="text-sm font-medium">{name}</p>
			<p class="text-muted-foreground text-xs tabular-nums">527 GB</p>
		</div>
	{/each}
</Scroller>

Notes

Notes

Reach for it when a strip of cards, filters or thumbnails is bigger than the space it has and cutting it down is not an option. It is not a carousel: nothing advances on its own, nothing wraps, and there are no dots — it is the strip you already have, with a way to get at the rest of it.

Where the buttons go

They float over the strip at every width. On a device that can hover they fade in on hover — or on a focus anywhere in the component, so the keyboard reaches them the same way. While hidden they are pointer-events: none, so nothing invisible swallows a click. On a touchscreen, where nothing would ever reveal them, they stay shown: a control you cannot discover is not one.

The strip carries padding of exactly the room a floating button takes, which is what keeps the first and last item whole rather than tucked under one.

showButtons={false} drops both buttons and that padding. The strip still scrolls under a trackpad swipe, a tilt wheel, a touch drag and the keyboard — only the mouse with a plain vertical wheel loses its way in, since that wheel belongs to the page (see below); pair it with yieldWheel={false} if a plain mouse has to move the strip.

Up to 0.15.2 the buttons stood beside the strip below sm, in the flow. They now float there too, so a strip on a phone gains the padding it already had on a wider screen — a layout tuned to the old narrow width may want a look.

A disabled edge dims its icon rather than fading the whole button, which is the one place this kit does not use disabled:opacity-50 — opacity is what the reveal is made of here, and a button cannot be half-disabled and fully hidden at the same time.

Whole items, not round numbers

A press moves by step (or just under a viewport, unset) and then lands on an item boundary: the furthest one the press reaches, or the very next one if a single item is bigger than a step. So whatever the reader is scrolling towards arrives whole, never half cut off at the edge, and Home/End put the first or last item flush against its own end.

The rest of it

  • The children are laid out for you, in a flex row (gap-3) or, with orientation="vertical", a flex column. Give each item a shrink-0, or the strip will squeeze to fit instead of overflowing. viewportClass is where a different gap or padding goes; class is the outer frame — and a vertical one needs a height there (class="h-64"), since a column with nothing to overflow has nothing to scroll.

    Vertical
    a vertical scroller
  • The scrollbar is hidden, not the scrolling. The wheel, a trackpad swipe, a touch drag and the keyboard all still work, and the region is a tab stop so a keyboard-only user can reach what is off-screen. That is why it is a hidden scrollbar rather than overflow: hidden.

  • A vertical wheel belongs to the page. Left alone, a browser turns a vertical wheel over a horizontal scroll box into a sideways scroll, so a full-width strip swallows the scroll of a reader who was only passing through. This one hands that event back to whatever was scrolling beneath it, and moves sideways only under a sideways gesture — a trackpad swipe, a tilt wheel, a touch drag — or the buttons and the keyboard, which is what a plain mouse uses. yieldWheel={false} restores the browser's own behaviour; a vertical strip is left to scroll natively either way.

  • onEdgeChange fires whenever either end is reached or left — for a fade, a shadow, or a "you have seen everything" note.

Loading as it goes

onLoadMore turns it into an endless strip. It fires as the far end comes within loadThreshold (200px by default), and then stays quiet until the content has actually grown — so a batch that returns nothing ends the run instead of asking on every scroll event. A strip whose items do not fill it is already at its end, which is how the first screen fills itself.

<Scroller aria-label="Recent files" onLoadMore={next} loading={pending}>
	{#each files as file (file.id)}
		<Card class="w-48 shrink-0">{file.name}</Card>
	{/each}
</Scroller>

loading is yours to hold: it puts a Spinner at the far end, marks the region aria-busy, and suppresses further asks while the batch is in flight. Set it before you fetch and clear it when the items land — a fetch that fails should clear it too, or the strip goes quiet with a spinner on it forever.

Layout · Card · StatCard · Accordion · AccordionItem · Collapsible · Separator · Table


← All components · Getting started · Theming · source