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 |
|---|---|
![]() |
![]() |
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, withorientation="vertical", a flex column. Give each item ashrink-0, or the strip will squeeze to fit instead of overflowing.viewportClassis where a different gap or padding goes;classis 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 
-
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. -
onEdgeChangefires 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
driftwood
Layout
Actions
Forms
- Field
- Input
- FileInput
- FileList
- Textarea
- CodeEditor
- Combobox
- RadioGroup
- NumberInput
- InlineEdit
- Label
- Select
- MultiSelect
- DatePicker
- TagsInput
- Checkbox
- Switch
- Slider
Feedback
Charts
Overlays
Navigation
Generated from the source by pnpm wiki · repository · v0.24.0

