Sync
Keeps every device on the same page.
A horizontal row of equal-rank items that scrolls only when it runs out of room.
Sync
Keeps every device on the same page.
Audit
Every change, with who and when.
Reports
Numbers your board actually reads.
Access
Roles, invites and passkeys.
API
Everything the UI does, scriptable.
<script lang="ts">
import { Scroller } from '@urbicon-ui/blocks';
const label = 'Main features';
const cards = [
{ id: 'sync', title: 'Sync', body: 'Keeps every device on the same page.' },
{ id: 'audit', title: 'Audit', body: 'Every change, with who and when.' },
{ id: 'reports', title: 'Reports', body: 'Numbers your board actually reads.' },
{ id: 'access', title: 'Access', body: 'Roles, invites and passkeys.' },
{ id: 'api', title: 'API', body: 'Everything the UI does, scriptable.' }
];
</script>
<Scroller
{label}
>
{#each cards as card (card.id)}
<article class="border-border-subtle bg-surface-elevated rounded-contain border p-4">
<p class="text-text-primary text-sm font-semibold">{card.title}</p>
<p class="text-text-secondary mt-1 text-sm">{card.body}</p>
</article>
{/each}
</Scroller>A Scroller is a horizontal row that scrolls only when it runs out of room. Where every item fits
it stays an ordinary row, by default without a scrollbar, buttons or a tab stop. Once it
overflows it becomes a focusable role="group" the keyboard
can scroll, with snapping and jump buttons.
Every direct child is one item. The row sets each child's width from itemBasis and stretches them to a common height, so a
card component you already have drops in unchanged. It takes any CSS length (16rem by default, a clamp() for a responsive row) or auto, which leaves every item at its own content width. label is required, because an unnamed group is a nameless box
to a screen reader.
The jump buttons appear on their own once the row overflows. controls="none" leaves the row to the scrollbar and the
keyboard, controls="always" keeps them mounted and
disables them at the ends, and indicator="dots" adds a dot
per position the row can scroll to.
align | Reach for it when |
|---|---|
start (default) | Comparable, equal-rank items you sweep across: feature cards, media, filter chips. |
center | One item leads at a time and the count should stay visible. Pair it with emphasis to lift the centred item and indicator="dots" to show the count. |
Reach for a Tab panel instead when one item should be presented at a time, like a screenshot or a live demo. It carries the right semantics for that. A Scroller is for items you compare by sweeping across them.
itemBasis sets each item's width, which decides when the row overflows. The same three cards sit in a wide and a narrow container. Below, the row runs out of room and picks up snapping, a tab stop and its jump buttons. The narrow cell needs min-w-0, which the snippet shows.Room for all three, a plain row
Sync
Keeps every device on the same page, offline included.
Audit
Every change recorded, with who changed it and when.
Reports
Numbers your board actually reads.
Same cards, out of room, now scrollable
Sync
Keeps every device on the same page, offline included.
Audit
Every change recorded, with who changed it and when.
Reports
Numbers your board actually reads.
<!-- In a container with room for every card: an ordinary row. -->
<Scroller label="Main features" itemBasis="13rem">
{#each features as feature (feature.id)}
<FeatureCard {...feature} />
{/each}
</Scroller>
<!-- The same row in a narrow grid or flex cell. Without min-w-0 the cell
refuses to shrink below its content and widens its column instead of
letting the row scroll. -->
<div class="min-w-0 max-w-[22rem]">
<Scroller label="Main features" itemBasis="13rem">
{#each features as feature (feature.id)}
<FeatureCard {...feature} />
{/each}
</Scroller>
</div>align=center makes the middle item the subject and pads the track so the first and last can reach the centre. Keep itemBasis narrow enough that a neighbour still peeks in beside the centred item: make the items much wider and the padding takes over the row, which the component warns about in DEV. emphasis=strong lifts whichever card has arrived in the middle, and indicator=dots shows a count the partly-visible row cannot.Sync
Keeps every device on the same page, offline included.
Audit
Every change recorded, with who changed it and when.
Reports
Numbers your board actually reads.
Access
Roles, invitations and passkeys out of the box.
API
Everything the interface does, scriptable.
<Scroller
label="Main features"
itemBasis="15rem"
align="center"
emphasis="strong"
indicator="dots"
>
{#each features as feature (feature.id)}
<FeatureCard {...feature} />
{/each}
</Scroller>itemBasis=auto lets each chip keep its own width instead of taking a shared one, and a tight gap holds the bar together. No indicator: a dot per chip would be noise, and the chips already label themselves.<Scroller label="Filters" itemBasis="auto" gap="sm">
{#each filters as filter (filter)}
<Badge variant="outlined">{filter}</Badge>
{/each}
</Scroller>slotClasses reaches the inner slots by name. The viewport slot is the scroll track, so a wash and a container radius there frame the row as a tray while the cards keep their elevated surface. The other slots are root, controls, control, indicator and dot, listed with the rest of the props below.Sync
Keeps every device on the same page, offline included.
Audit
Every change recorded, with who changed it and when.
Reports
Numbers your board actually reads.
Access
Roles, invitations and passkeys out of the box.
API
Everything the interface does, scriptable.
<Scroller
label="Main features"
itemBasis="14rem"
slotClasses={{ viewport: 'bg-primary-subtle rounded-contain px-4 py-3' }}
>
{#each features as feature (feature.id)}
<FeatureCard {...feature} />
{/each}
</Scroller>This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
While the row overflows, the scroll container takes a tab stop and becomes a named role="group", which is what label names. A row that fits takes no stop, so a keyboard
user never pays a press for a row with nothing to scroll.
Once the container has focus, ← →, Home, End and Page Up / Page Down scroll it. The browser handles those, with the platform's own snapping and inertia, and the component adds no key handling of its own. Items that are themselves focusable (links, buttons) stay in the tab order and scroll into view when focused, so a row of clickable cards keeps working without the container stop.
Jump buttons and dots are real buttons with labels. Dots appear only while the row overflows
and has more than one position to rest at, and so do the jump buttons unless controls="always" pins them. The dot the row
currently rests at carries aria-current. A
jump button moves the row by one viewport where align is start, and by one item where it is center, so a long chip bar pages instead of stepping
through thirty chips.
A dot stands for a position the row can scroll to, not for an item. On a centred row that
comes to one dot per item. On a start-aligned row the last items share the end of the scroll
range, so one dot covers them and says so (“Items 4–5 of 5”). Where that count would confuse
more than it orients, leave indicator off.
Smooth scrolling and the emphasis lift both collapse under prefers-reduced-motion. The lift follows
scroll position rather than a clock, and where the browser does not support that (Firefox
before 156) the row behaves identically, just flat.
Prop | Type | Default | Description | |
|---|---|---|---|---|
children required | Snippet | — | The row's items. Each direct child becomes one snap target — width, snap alignment and (with emphasis) the lift are applied for you. Required. | |
label required | string | — | Accessible name for the row, e.g. "Main features". Required: once the row
overflows it becomes a focusable role="group", and an unnamed group is a
nameless box to a screen reader. It is deliberately not optional-with-a-
fallback — a generic default name would be worse than none. | |
align | startcenter | 'start' | Where an item comes to rest when the row snaps. start is the ordinary
overflow row. center is a stage: the middle item is the subject, and the
track is padded so the first and last item can reach the middle too.
center needs items narrow enough that roughly three are visible with two
peeking. Make them much wider and the centring padding takes over the row —
one card adrift in empty space, which reads as a layout bug rather than a
stage. The component warns about that in DEV. | |
class | string | — | Extra classes merged onto the root container (the column holding the row and its control bar). | |
controls | autoalwaysnone | 'auto' | Previous/next buttons. auto shows them only while the row overflows —
on a viewport where everything fits there is nothing to navigate, and
controls for a problem that does not exist are just clutter. always keeps
them mounted (disabled at the ends); none omits them. | |
emphasis | nonesubtlestrong | 'none' | Lift the item in the middle of the scrollport — a scale plus an elevation
step, driven by scroll position via CSS. subtle is a light touch, strong
is visible across a room; past roughly strong a row wobbles while
scrolling and pulls attention away from reading, which is why this is a
scale and not a free number. Retune per instance with
--blocks-scroller-emphasis-scale / -shadow.
**Requires align="center"** and is a no-op otherwise (with a DEV warning):
the lift marks the item that has arrived in the middle, and a start-aligned
row has no middle. Neighbours are never dimmed or blurred — the point is to
mark the middle, not to hide the rest. Respects prefers-reduced-motion,
and where animation-timeline is unsupported it simply does nothing. | |
gap | xssmmdlg +1 more | 'md' | Space between items. | |
indicator | nonedots | 'none' | Position indicator. dots renders one button per **resting place** —
every dot jumps to its own destination and the current one carries
aria-current. On a centred row that is one dot per item. On a
start-aligned row the trailing items that share the end of the scroll
range share one dot, labelled with their range ("Items 4–5 of 5"): a row
has only as many distinct resting places as it can scroll to, and a dot
per item would light up elsewhere than the press. Leave it off for long
chip bars, where a dot per position is noise either way. | |
itemBasis | string | '16rem' | Width of each item, as any CSS length ('18rem', 'clamp(14rem,60vw,22rem)').
This is what decides when the row overflows, so it is a prop rather than a
class: with align="center" the component needs it to compute the edge
padding. Override per-item layout via slotClasses.viewport if you need
responsive widths. | |
nextLabel | string | 'Next' (localised) | Accessible label for the next button. | |
onActiveChange | (index: number) => void | — | Fires when the item at the snap anchor changes, with its zero-based index. Intentionally coarse — there is no per-frame scroll-position callback, because the CSS-native carousel primitives this component is meant to be swapped for one day could not honour one. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Scroller: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
previousLabel | string | 'Previous' (localised) | Accessible label for the previous button. | |
slotClasses | Partial<Record<ScrollerSlots, string>> | — | Per-slot class overrides merged with tv() styles. Slots: root (the column —
what class also targets) | viewport (the scroll container; also where
per-item rules like width live, via [&>*]:…) | controls (the bar under the
row) | control (a previous/next button) | indicator (the dot group) | dot. | |
snap | proximitymandatorynone | 'proximity' — or 'mandatory' when align="center" | Snap strictness. proximity snaps when you release nearby and otherwise
leaves scrolling alone; mandatory always lands on an item — right when one
item at a time is the unit, but it can skip past content sitting between two
snap points. none scrolls freely.
The default follows align, because the two alignments mean different
things: a start row is a list you sweep across (proximity), a center
row is a stage whose middle has to land on something (mandatory). | |
unstyled | boolean | — | Remove all default tv() classes; combine with slotClasses to rebuild the look. Note that this also strips the layout rules that make the row scroll and snap. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children' | 'class') | |
...ScrollerVariants variant | VariantProps | — | Styling variants from ScrollerVariants |
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
ScrollerProps | interface | props | 0 | — | |
ScrollerVariants | type | variant | 0 | — | |
ScrollerSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. |
import { Scroller } from '@urbicon-ui/blocks';
import type { ScrollerProps, ScrollerAlign } from '@urbicon-ui/blocks';