Sync
Keeps every device on the same page.
A horizontal row of equal-rank items that scrolls only when it has to. On a wide viewport it is an ordinary row — no scrolling, no arrows, no dots, and none of the accessibility duties a scroll container carries. Once it overflows it snaps to item boundaries and becomes a named, keyboard-reachable scroll region. It never auto-rotates.
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>Room for all three — an ordinary 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 markup, out of room — now a scroll region
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.
<!-- one component, two containers -->
<Scroller label="Main features" itemBasis="13rem">
{#each features as feature (feature.id)}
<FeatureCard {...feature} />
{/each}
</Scroller>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="22rem"
align="center"
emphasis="strong"
indicator="dots"
>
{#each features as feature (feature.id)}
<FeatureCard {...feature} />
{/each}
</Scroller><Scroller label="Filters" itemBasis="auto" gap="sm">
{#each filters as filter (filter)}
<Badge variant="outlined">{filter}</Badge>
{/each}
</Scroller><!-- one length, every viewport -->
<Scroller label="Products" itemBasis="clamp(14rem, 70vw, 22rem)">…</Scroller>
<!-- or take over per-item layout entirely -->
<Scroller
label="Products"
slotClasses={{ viewport: '[&>*]:basis-[70vw] md:[&>*]:basis-72' }}
>…</Scroller><div class="grid lg:grid-cols-[1fr_20rem]">
<div class="min-w-0"> <!-- ← without this the row pushes the column wide -->
<Scroller label="Main features" itemBasis="14rem">…</Scroller>
</div>
<aside>…</aside>
</div><Scroller
label="Main features"
align="center"
emphasis="strong"
style="--blocks-scroller-emphasis-scale: 1.06;
--blocks-scroller-emphasis-shadow: var(--blocks-shadow-lg);"
>…</Scroller>unstyled strips all of it — including the layout rules that make the row scroll and snap, so rebuild those too.<Scroller
label="Main features"
slotClasses={{
controls: 'justify-end pt-2',
dot: 'size-8'
}}
>…</Scroller>While the row overflows, the scroll container takes a tab stop and becomes a named role="group". This is the defect in most
media rows on the web: a scrollable container that is not focusable cannot be scrolled by
keyboard at all — Safari does not adopt it into the tab order on its own. The tab stop is
conditional because the inverse is a defect too: a stop on a row with nothing to scroll
costs a press and does nothing.
Arrow keys, Home, End and Page Up/Down scroll the focused container — handled by the browser, with the platform's own snapping and inertia. The component adds no key handling of its own, so nothing competes with the native behaviour. Items that are themselves focusable (links, buttons) stay in the tab order and scroll into view when focused.
The jump buttons are real buttons with labels, disabled at the ends rather than hidden — a
control that disappears takes its width with it and shifts the row. Dots are buttons that
jump to their item and carry aria-current; decorative dots that merely
look clickable are worse than none. Both appear only while the row overflows.
A dot stands for a resting place, not for an item. On a centred row that is the same thing — every item has its own turn. On a start-aligned row the trailing items share the end of the scroll range, so they 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. Every dot therefore does exactly what it promises.
The native scrollbar stays visible while nothing else makes the promise that there is more to see. Once jump buttons or dots are on screen they carry it, and the scrollbar steps aside rather than stacking a third indicator on the other two.
Smooth scrolling and the emphasis lift both collapse under prefers-reduced-motion. The lift is driven by animation-timeline: view(), so it follows
scroll position rather than a clock — where that is unsupported (Firefox before 156) the row
behaves identically, just flat. Nothing here moves on its own: there is no auto-rotation, by
design. Motion the user did not ask for competes with reading, and the click-through
evidence on rotating banners has been unambiguous for over a decade.
A Scroller fits when the items are comparable and the user should be able to sweep across
them. When one item at a time should be presented — a screenshot, a live demo — a
tab panel is the stronger pattern, and Tab already provides it with the right semantics.
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';