Skip to main content
Urbicon UI

Scrollerexperimental

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.

Playground

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.

Align
Item width
Snap
Controls
Indicator
Emphasis
<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>

01 Examples

The same row, twice — it scrolls only when it must

Identical markup in a wide and a narrow container. Above there is room for every card, so it stays an ordinary row: no scrollbar, no buttons, no tab stop, and none of the duties a scroll container carries. Below, the same row runs out of room and picks up snapping, a keyboard-reachable scroll region and its jump controls. Nothing is hidden where there is space for it — which is why a desktop layout needs no arrows and no dots.

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>

Centred stage

A different job from the row above: not “compare five cards side by side”, but “see that there are five, and read one of them”. The card width is chosen so the row always overflows — that is what gives it a middle to centre. Neighbours stay at full opacity: the peeking cards are what carry the “there is more” message, so dimming them would destroy the point. Dots supply the count a partly-visible row can no longer show.

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>

Filter bar

Narrow items, tight gap, no indicator — a dot per chip would be noise, and the chips already label themselves. Because the items are small, the jump buttons travel a viewport at a time rather than one chip.
All Active Draft Archived Shared with me Recently changed Needs review Mine
<Scroller label="Filters" itemBasis="auto" gap="sm">
  {#each filters as filter (filter)}
    <Badge variant="outlined">{filter}</Badge>
  {/each}
</Scroller>

02 Customization

Responsive item widths

itemBasis takes any CSS length, so a clamp() covers the whole range without a media query — and the row keeps deciding for itself when it overflows. For anything more involved, target the items through the viewport slot.
<!-- 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>

Inside a grid or a flexbox: give the parent min-w-0

The Scroller itself carries min-w-0, but a grid or flex ITEM defaults to min-width:auto and refuses to shrink below its content. Drop a row into such a column without min-w-0 and it will widen the column instead of scrolling — the row looks broken, and the cause is one level up. This is the single most common way to make a Scroller misbehave.
<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>

Retuning the emphasis lift

The lift reads two custom properties, so its size and elevation tune per instance without a prop for each. Keep it small: past roughly 1.05 the row wobbles while scrolling and pulls attention away from reading.
<Scroller
  label="Main features"
  align="center"
  emphasis="strong"
  style="--blocks-scroller-emphasis-scale: 1.06;
         --blocks-scroller-emphasis-shadow: var(--blocks-shadow-lg);"
></Scroller>

Slots

root (the column holding the row and its control bar) · viewport (the scroll container, and where per-item rules live) · controls (the bar under the row) · control (a jump button) · indicator (the dot group) · dot. 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>

03 Accessibility

Keyboard

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.

Controls and indicator

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.

Motion

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.

Choosing this over a tab panel

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.

04 API Reference

18 props
18 props 2 required
Prop
Type
Default
Description

05 Types

Local type definitions used by this component.

3 types
Name
Kind
Category
Used by
Description

06 Installation

Import

import { Scroller } from '@urbicon-ui/blocks';
import type { ScrollerProps, ScrollerAlign } from '@urbicon-ui/blocks';