Skip to content

ScrollZone

A strip of anything, laid out in one direction and scrolled in it. Cards, chips, avatars or thumbnails run across the box or down it, in as many lines as you ask for, with a pair of buttons for the pointer that has no wheel and no finger.

tsx
import { ScrollZone } from 'neba';

<ScrollZone label="Continue watching" spacing={3}>
  {shows.map((show) => (
    <Card key={show.name} className="w-40" title={show.name} />
  ))}
</ScrollZone>;

Props

PropTypeDefaultDescription
orientationshared'horizontal' | 'vertical''horizontal'Which way the children run, and therefore which way the zone scrolls
linesnumber1How many rows a horizontal zone uses before it starts a new column: columns, in a vertical one. 2 is the shelf that holds twice as much in the same width
spacingnumber2The gap between children, on Tailwind's spacing scale: 2 is 0.5rem, the same ladder GridContainer's spacing is on
buttons'auto' | 'always' | 'none''auto'When the scroll buttons are drawn. auto draws neither while everything fits and, at an end, removes an overlay button but disables an inline one, whose lane is held open either way. always is both from the first paint, none is neither
buttonPlacement'inline' | 'overlay''inline'Whether the buttons sit beside the strip or over it. inline stops the scroller where the button starts, so an item is cut off at its edge rather than sliding under it, and the lane is kept even while that button has nowhere to go, which is why an auto button is disabled there rather than removed
mode'item' | 'page' | 'hold''item'What pressing a button does: item moves to the next child, page moves by everything on screen, hold scrolls for as long as it is held. A press too short to be a hold falls back to one item
stepnumber1How many children one press moves, in item mode
speednumber900How fast a held button scrolls, in pixels a second
snapbooleanfalseSnaps the nearest child to the leading edge when the scrolling stops: dragging and the wheel included, not only the buttons
dragbooleantrueLets a mouse or a pen drag the strip along, the way a finger already does. Touch is left to the browser, whose own scrolling brings momentum and a scrollbar with it
wheelbooleanfalseTurns a wheel rolled over the strip into travel along it, and holds it at the ends too: the pointer leaving the strip is what gives the page its wheel back. A sideways trackpad swipe and a vertical zone are left alone
scrollbarbooleanfalseShows the native scrollbar
variantshared'solid' | 'outline' | 'text''solid'Weight of the scroll buttons. The zone itself draws nothing
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The scroll buttons' size, and how far in from the edge they sit
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role of the buttons and the focus ring
densityshared'default' | 'compact''default'The buttons' padding only
localestringWhich language the buttons name themselves in: a BCP 47 tag. Unsupported tags fall back to English
labelstringlocale's wordWhat the scrollable region is called: "Categories", "Recent files". The strip is always a tab stop, because a reader with no pointer has to be able to move it; without a name the locale's generic word stands in
previousLabelstringThe back button's own name
nextLabelstringThe forward button's own name
childrenReactNodeWhat is being laid out. Every top-level child is one item of the strip

Every other <div> attribute passes through to the root. The shared axes (variant size color density orientation) are defined in prop conventions.

Examples

orientation and lines

orientation decides which way the strip runs and therefore which way it scrolls. lines is how many rows a horizontal zone fills before it starts a new column: two lines hold twice as much in the same width, and the strip is still one scroll.

spacing is the gap between children, on the same scale as GridContainer's: 2 is 0.5rem.

mode

What a press of a button does. item moves to the next child along and step says how many at a time; page moves by everything currently on screen; hold scrolls for as long as the button is held, at speed pixels a second. A press too short to be a hold moves one item, so a quick tap is never a dead press.

buttons

auto (the default) draws neither while everything fits. At an end it does whichever costs less: an overlaid button with nowhere to go is removed, an inline one is disabled, since its lane is held open either way. always draws both from the first paint, including while everything still fits, which is what a strip whose content arrives later wants. none draws neither and leaves the strip to dragging, the arrow keys and whatever the pointer can already swipe with.

snap brings the nearest child to the leading edge whenever the scrolling stops, however it was scrolled.

buttonPlacement

inline (the default) puts the buttons beside the strip: the scroller stops where the button starts, so an item is cut off at the button's edge rather than sliding beneath it, and the button is legible over the page rather than over whatever it landed on. overlay puts them over the ends of the strip instead, which keeps every pixel of the box for content and lets an item pass under a button.

An inline button keeps its lane even while it has nowhere to go, or the strip would resize under the pointer that had just reached the end of it. That is also what buttons="auto" follows at an end: the lane is paid for either way, so an inline button stays there and is disabled, while an overlaid one is removed.

Running down the page

A vertical zone needs a height to scroll inside, and it takes it from the component: the root is a flex column and the scroller fills it, so className="h-full" on a box with a height is all it needs.

drag

A finger already scrolls the strip, because the mechanism is an ordinary scroll container and touch scrolling is the browser's own: with momentum, rubber-banding and a scrollbar that no handler reproduces. drag adds the same gesture for a mouse or a pen, and the click that would otherwise follow a real drag is swallowed, so pulling the strip past a card never opens it.

tsx
<ScrollZone drag={false} scrollbar>
  {items}
</ScrollZone>

wheel

A mouse has one wheel and it points down the page, which is the one axis a horizontal strip does not run along. wheel turns a wheel rolled over the strip into travel along it.

It is off by default, because a wheel taken from the page is the page's: a reader who meant to scroll past the shelf would be held by it instead, and this strip already has buttons for the pointer that has no other way along.

What it takes it keeps, at the ends as well, so a flick that runs out of strip does not turn into a jump down the article. Moving the pointer off the strip is what gives the page its wheel back. A trackpad swiping sideways is left alone, since that already scrolls the strip, and a vertical zone ignores the prop.

Accessibility

  • The strip is focusable and scrolls with the arrow keys, which is the browser's own key handling on a scroll container, so it is already right under RTL.
  • label names the region and is what a screen reader reads before its contents. Without one the strip is focusable but unnamed.
  • The scroll buttons are real buttons with real names, and previousLabel / nextLabel (or locale) decide what those names are.
  • In hold mode the buttons answer Enter and Space the same way they answer a press, scrolling while the key is down.
  • Nothing inside the strip is hidden when it is off screen: it is genuinely reachable by scrolling, and aria-hidden on it would be a lie a keyboard reader would fall into.

Released under the MIT License