Skip to content

PlScrollZone

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 neither a wheel nor a finger.

React
tsx
import { PlCard, PlScrollZone } from 'plass-ui';

<PlScrollZone label="Continue watching" spacing={3}>
  {shows.map((show) => (
    <PlCard key={show.name} className="w-40" title={show.name} />
  ))}
</PlScrollZone>;
dart
import 'package:plass_ui/plass_ui.dart';

PlScrollZone(
  label: 'Continue watching',
  spacing: 12,
  children: <Widget>[
    for (final Show show in shows)
      SizedBox(width: 160, child: PlCard(title: Text(show.name))),
  ],
);

Props

PropTypeDefaultDescription
orientationsharedPlassResponsive<'horizontal' | 'vertical'>'horizontal'Which way the children run, and therefore which way the zone scrolls
linesnumber1How many rows a horizontal zone fills before it starts a new column — columns, for a vertical one
spacingnumber2The gap between children, on PlGrid's own ladder: 2 is 0.5rem
buttons'auto' | 'always' | 'none''auto'When the scroll buttons are drawn. auto draws only the one that has somewhere to go
buttonPlacement'inline' | 'overlay''inline'Whether the buttons sit beside the strip or over it
mode'item' | 'page' | 'hold''item'What pressing one does: to the next child, by a screenful, or for as long as it is held
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
dragbooleantrueLets a mouse or a pen drag the strip along. Touch is left to the browser
wheelbooleantrueTurns a vertical wheel over a horizontal zone into scrolling along the strip
overscrollshared'auto' | 'contain''contain'What the strip does with a gesture it has run out of room for. contain keeps it, auto hands it to the page. A strip everything fits in holds nothing back either way
scrollbarbooleanfalseShows the native scrollbar
variantshared'solid' | 'glass' | 'ghost''glass'What the scroll buttons are made of. The zone itself draws no sheet
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The size of the buttons and how far in from the edge they sit
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
labelstringWhat the scrollable region is called — "Categories", "Recent files"
previousLabelstring'Previous'The button's name. Never drawn
nextLabelstring'Next'The button's name. Never drawn
PropTypeDefaultDescription
children * List<Widget>What is being laid out. Every child is one item of the strip
orientationsharedPlassResponsive<PlassOrientation>'horizontal'Which way the children run, and therefore which way the zone scrolls
linesint1How many rows a horizontal zone fills before it starts a new column — columns, for a vertical one
spacingdouble8The gap between children, in logical pixels. Dart has no rem
buttonsPlScrollZoneButtonsPlScrollZoneButtons.autoWhen the scroll buttons are drawn. auto draws only the one that has somewhere to go
buttonPlacementPlScrollZoneButtonPlacementPlScrollZoneButtonPlacement.inlineWhether the buttons sit beside the strip or over it
modePlScrollZoneModePlScrollZoneMode.itemWhat pressing one does: to the next child, by a screenful, or for as long as it is held
stepint1How many children one press moves, in item mode
speeddouble900How fast a held button scrolls, in pixels a second
snapboolfalseSnaps the nearest child to the leading edge when the scrolling stops
dragbooltrueLets a mouse drag the strip along. Touch and a trackpad already scroll
wheelbooltrueTurns a vertical wheel over a horizontal zone into scrolling along the strip
overscrollsharedPlassOverscrollPlassOverscroll.containWhat the strip does with a gesture it has run out of room for. contain keeps it, auto hands it to whatever is behind the strip
scrollbarboolfalseShows the native scrollbar
controllerScrollController?Drive the scroll from outside. Left out, the zone owns one of its own
variantsharedPlassVariantPlassVariant.glassWhat the scroll buttons are made of. The zone itself draws no sheet
sizesharedPlassSizePlassSize.mdThe size of the buttons and how far in from the edge they sit
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
labelString?What the scrollable region is called — "Categories", "Recent files"
previousLabelString'Previous'The button's name. Never drawn
nextLabelString'Next'The button's name. Never drawn

Every other <div> attribute passes through to the root.

What the shared axes mean across the library is in prop conventions.

Composition

The mechanism is an ordinary scroll container, and everything the component offers is a way of driving one. Swiping, two-finger dragging on a trackpad and the scrollbar are the platform's own and are never intercepted. What is added on top is a pair of buttons for a pointer with neither a wheel nor a finger, a mouse drag that reads as pulling the strip rather than paging it, and the vertical wheel a horizontal strip would otherwise ignore.

Nothing is transformed. A translated track would have to argue for an exception to the house rule; a scroll offset does not, and it is also what makes the strip run the other way under RTL without being told, and keeps the scrollbar honest.

It draws no sheet of its own, and there is no elevation to give it one. A shelf is a way of laying children out, and the children arrive with their own surfaces. variant, size and color reach the two buttons, which are real PlIconButtons.

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.

It is responsive, so a set can run one way on a phone and the other on a laptop. A server renders the xs entry and the browser corrects it on hydration.It is resolved against the window's width during build, so the first frame is already right. See breakpoints.

spacing is the gap between children.

It is on the same ladder PlGrid's own spacing is: 2 is 0.5rem.

It is a length in logical pixels. Dart has no rem, and every other measurement in this package is already the same number the other one writes in rem.

React

buttons and snap

auto, the default, draws neither button while everything fits. Once the row overflows, both are drawn and the one with nowhere to go is disabled, exactly as always draws it: auto decides whether the strip has scroll buttons at all, not which of them exists this second. always draws both from the first paint, which is what a strip whose content arrives later needs. none draws neither and leaves the strip to the wheel, the arrow keys and dragging.

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

React

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. It is drawn in that lane, disabled, rather than held invisible: the lane is paid for either way, and a reserved empty one beside a strip reads as odd padding on one side of the box. An overlay button has no lane to keep, so it is removed instead.

React

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 instead, so a quick tap is never a dead press.

tsx
<PlScrollZone mode="hold" speed={1200} buttons="always">
  {items}
</PlScrollZone>
dart
PlScrollZone(
  mode: PlScrollZoneMode.hold,
  speed: 1200,
  buttons: PlScrollZoneButtons.always,
  children: items,
);

An item is measured rather than assumed: the children of a scroll zone are whatever the caller put there, so no two of them are necessarily the same width. That measurement is also what makes lines work, four children stacked two by two are two columns, and one press should move one column rather than half of one.

drag

A finger already scrolls the strip, because the mechanism is an ordinary scroll container and touch scrolling is the platform's own, with momentum, rubber-banding and a scrollbar that no handler reproduces. drag adds the same gesture for a mouse.

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

The click that would otherwise follow a real drag is swallowed, so pulling the strip past a card never opens it.

dart
PlScrollZone(drag: false, scrollbar: true, children: items);

Flutter leaves the mouse out of dragDevices by default, which is the same judgement the browser's own scroll containers make and the same one this reverses: dragging a shelf with a mouse is unusual enough to have to be asked for, and a shelf is exactly the place that asks.

wheel

A vertical wheel over a strip that runs across the box scrolls it along. A mouse has one wheel and it points the wrong way for a horizontal strip, and what happens there is the platform's own business, which is the problem, since it makes the answer depend on which browser or which machine the reader is on. The pointer being on the strip is them saying which of the two things under it they meant to move.

Only the vertical half of a gesture: a trackpad's two fingers and a tilt wheel already scroll the strip sideways and are left alone. A vertical zone is left alone entirely, since the wheel already runs the way it does. What happens once the strip has run out of room is overscroll.

tsx
<PlScrollZone wheel={false}>{items}</PlScrollZone>

Shift held down is a horizontal gesture too, and is the browser's.

dart
PlScrollZone(wheel: false, children: items);

A horizontal Scrollable reads the horizontal half of a scroll and a mouse wheel only ever produces the vertical one, so without this a shelf under the pointer does not move at all.

overscroll

A shelf that has reached its last card is still the thing under the pointer, and contain, the default, keeps the gesture there. The page does not start moving because one more notch of the wheel arrived after the strip ran out, which is a jump the reader did not ask for and usually cannot see coming.

auto gives the wheel back to the page at the ends, the way a nested scroller does when it is left alone. Even then the strip keeps a gesture that was scrolling it a moment ago: the page takes over once the reader has paused, not in the middle of a flick.

Two things keep the containment from becoming a trap. A strip everything fits in is not a scroller and holds nothing back either way, and only the axis the strip runs on is contained, so a finger sliding down a horizontal shelf still scrolls the page.

tsx
<PlScrollZone overscroll="auto">{items}</PlScrollZone>

The other axis is overscroll-behavior on the scroller itself, which is what stops a two-finger swipe past the end of the shelf from going back a page.

dart
PlScrollZone(overscroll: PlassOverscroll.auto, children: items);

A strip that runs out claims the scroll anyway rather than passing it to whatever is behind it, which is the same decision contain makes in the browser.

Accessibility

  • label names the region and is what a screen reader reads before its contents. Without one the strip has no name at all.
  • The scroll buttons are real buttons with real names, and previousLabel / nextLabel decide what those names are. A disc with a chevron in it has no accessible name of its own, which is the defect PlIconButton's label exists to make impossible.
  • Nothing inside the strip is hidden while it is off screen: it is genuinely reachable by scrolling, and hiding it would be a lie a keyboard reader would fall into.
  • 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.
  • In hold mode the buttons answer Enter and Space the same way they answer a press, scrolling while the key is down. A scroll affordance a pointer can use and a keyboard cannot is the one thing this must never be.
  • In hold mode a key press moves one item and the platform's own key repeat carries it, rather than the frame loop a held pointer gets. Either way the buttons are reachable from a keyboard, which is the thing that matters.

Differences from the React build

ReactFlutterWhy
children as JSXchildren: List<Widget>The idiom the rest of the package uses.
spacing on the spacing scalespacing, in logical pixelsThere is no rem. The numbers are the same either way.
a CSS grid with grid-template-rowsa row of columnslines is a fixed number of rows and as many columns as it takes, which is what grid-auto-flow: column says there. One thing does not carry over: a CSS grid gives every column the same row heights, and a row of columns does not.
controllerFlutter drives a scroll view with a ScrollController, and a caller who wants the offset should be handed the object that has it.
a pointer-held frame loop and a key-held onea pointer-held frame loopA held key repeats on its own here, and one item per repeat is what that produces.
densityThe buttons are PlIconButtons, and Flutter's has no density.
className, styleThere is no class list and no style attribute to pass through.

Released under the MIT License