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.
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>;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
| Prop | Type | Default | Description |
|---|---|---|---|
| orientationshared | PlassResponsive<'horizontal' | 'vertical'> | 'horizontal' | Which way the children run, and therefore which way the zone scrolls |
| lines | number | 1 | How many rows a horizontal zone fills before it starts a new column — columns, for a vertical one |
| spacing | number | 2 | The 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 |
| step | number | 1 | How many children one press moves, in item mode |
| speed | number | 900 | How fast a held button scrolls, in pixels a second |
| snap | boolean | false | Snaps the nearest child to the leading edge when the scrolling stops |
| drag | boolean | true | Lets a mouse or a pen drag the strip along. Touch is left to the browser |
| wheel | boolean | true | Turns 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 |
| scrollbar | boolean | false | Shows 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 |
| label | string | — | What the scrollable region is called — "Categories", "Recent files" |
| previousLabel | string | 'Previous' | The button's name. Never drawn |
| nextLabel | string | 'Next' | The button's name. Never drawn |
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | List<Widget> | — | What is being laid out. Every child is one item of the strip |
| orientationshared | PlassResponsive<PlassOrientation> | 'horizontal' | Which way the children run, and therefore which way the zone scrolls |
| lines | int | 1 | How many rows a horizontal zone fills before it starts a new column — columns, for a vertical one |
| spacing | double | 8 | The gap between children, in logical pixels. Dart has no rem |
| buttons | PlScrollZoneButtons | PlScrollZoneButtons.auto | When the scroll buttons are drawn. auto draws only the one that has somewhere to go |
| buttonPlacement | PlScrollZoneButtonPlacement | PlScrollZoneButtonPlacement.inline | Whether the buttons sit beside the strip or over it |
| mode | PlScrollZoneMode | PlScrollZoneMode.item | What pressing one does: to the next child, by a screenful, or for as long as it is held |
| step | int | 1 | How many children one press moves, in item mode |
| speed | double | 900 | How fast a held button scrolls, in pixels a second |
| snap | bool | false | Snaps the nearest child to the leading edge when the scrolling stops |
| drag | bool | true | Lets a mouse drag the strip along. Touch and a trackpad already scroll |
| wheel | bool | true | Turns a vertical wheel over a horizontal zone into scrolling along the strip |
| overscrollshared | PlassOverscroll | PlassOverscroll.contain | What 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 |
| scrollbar | bool | false | Shows the native scrollbar |
| controller | ScrollController? | — | Drive the scroll from outside. Left out, the zone owns one of its own |
| variantshared | PlassVariant | PlassVariant.glass | What the scroll buttons are made of. The zone itself draws no sheet |
| sizeshared | PlassSize | PlassSize.md | The size of the buttons and how far in from the edge they sit |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| label | String? | — | What the scrollable region is called — "Categories", "Recent files" |
| previousLabel | String | 'Previous' | The button's name. Never drawn |
| nextLabel | String | '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.
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.
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.
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.
<PlScrollZone mode="hold" speed={1200} buttons="always">
{items}
</PlScrollZone>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.
<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.
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.
<PlScrollZone wheel={false}>{items}</PlScrollZone>Shift held down is a horizontal gesture too, and is the browser's.
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.
<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.
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
labelnames 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/nextLabeldecide what those names are. A disc with a chevron in it has no accessible name of its own, which is the defectPlIconButton'slabelexists 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
holdmode 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
holdmode 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
| React | Flutter | Why |
|---|---|---|
children as JSX | children: List<Widget> | The idiom the rest of the package uses. |
spacing on the spacing scale | spacing, in logical pixels | There is no rem. The numbers are the same either way. |
a CSS grid with grid-template-rows | a row of columns | lines 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. |
| — | controller | Flutter 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 one | a pointer-held frame loop | A held key repeats on its own here, and one item per repeat is what that produces. |
density | — | The buttons are PlIconButtons, and Flutter's has no density. |
className, style | — | There is no class list and no style attribute to pass through. |