PlStack
Things piled up, overlapping, faces, cards, thumbnails, whatever you hand it. The box measures exactly what it draws, so a pile can sit in a sentence or a table cell at its real size.
import { PlAvatar, PlStack } from 'plass-ui';
<PlStack ring max={4} total={11} overflow={(hidden) => <PlAvatar initials={`+${hidden}`} />}>
<PlAvatar name="Ada Lovelace" src="/ada.jpg" />
<PlAvatar name="Grace Hopper" />
</PlStack>;import 'package:plass_ui/plass_ui.dart';
PlStack(
max: 4,
total: 11,
ring: BorderRadius.circular(999),
overflow: (int hidden) => PlAvatar(initials: '+$hidden'),
children: const <Widget>[
PlAvatar(name: 'Ada Lovelace'),
PlAvatar(name: 'Grace Hopper'),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| direction | PlassResponsive<'horizontal' | 'vertical' | 'diagonal'> | 'horizontal' | Which way the pile grows. diagonal is a fan rather than a true 45°: the horizontal advance is the item width less the overlap, and a component that takes arbitrary children does not know that width |
| overlap | number | string | a fraction of size | How far each item sits under the one before it, along the axis the pile flows on — a number of pixels or any CSS length |
| drop | number | string | whatever overlap resolved to | The step on the other axis, for direction="diagonal" only |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Which rung the default overlap comes off. It decides nothing else: a pile draws no surface of its own and has no type in it |
| max | number | — | How many items are drawn. Left out, every one of them is |
| total | number | — | How many there are altogether, when the pile was handed only the first few |
| overflow | (hidden: number) => ReactNode | — | Draws a last item standing for the ones that did not fit, given how many that is. A function rather than a node, because the number is the item |
| front | 'first' | 'last' | 'last' | Which end of the list is on top. last is what the DOM does on its own and what a row of faces wants; first is what a deck of cards is |
| scaleStep | number | 1 | What each item further back is multiplied by, compounding. On the standalone scale property, so a caller’s own transform survives |
| opacityStep | number | 1 | The same, for opacity |
| ring | boolean | false | Draws a hairline of the page’s own surface colour around each item. It lands on the element you passed, so it takes that element’s shape |
| children | ReactNode | — | The things in the pile |
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | List<Widget> | — | The things in the pile |
| direction | PlassResponsive<PlStackDirection> | PlStackDirection.horizontal | Which way the pile grows. diagonal is a fan rather than a true 45°: the horizontal advance is the item width less the overlap, and a component that takes arbitrary children does not know that width |
| overlap | double? | a fraction of size | How far each item sits under the one before it, in logical pixels, along the axis the pile flows on |
| drop | double? | whatever overlap resolved to | The step on the other axis, for direction="diagonal" only |
| sizeshared | PlassSize | PlassSize.md | Which rung the default overlap comes off. It decides nothing else: a pile draws no surface of its own and has no type in it |
| max | int? | — | How many items are drawn. Left out, every one of them is |
| total | int? | — | How many there are altogether, when the pile was handed only the first few |
| overflow | Widget Function(int hidden)? | — | Builds a last item standing for the ones that did not fit, given how many that is. A builder rather than a widget, because the number is the item |
| front | PlStackFront | PlStackFront.last | Which end of the list is on top. last is what the DOM does on its own and what a row of faces wants; first is what a deck of cards is |
| scaleStep | double | 1 | What each item further back is multiplied by, compounding. On the standalone scale property, so a caller’s own transform survives |
| opacityStep | double | 1 | The same, for opacity |
| ring | BorderRadius? | null | The corners of the hairline drawn around each item. A radius rather than a bool, which is the one place this diverges from React: there CSS gives a ring the element’s own border-radius for nothing, and here nothing can read a child’s shape |
| semanticLabel | String? | — | What the pile is a pile *of*. Name it when nothing else is saying so |
Every native <div> attribute passes straight through, aria-label included. A row of faces is a picture of a set, and what it is a set of is usually the sentence beside it.
semanticLabel names the pile, and the items keep their own semantics nodes under it, so a named pile is read as its name and then as what is in it.
size picks the default overlap off the ladder and decides nothing else. A pile draws no surface of its own and has no type in it, so there is no height to set and no ink to colour: the items are whatever they already were.
How the overlap is made
This is the decision the whole component is built on, and the one that is easy to get wrong.
An overlapping pile is tempting to build by translating each item back over the last. Do that and the pile is laid out one item wide: it paints outside its own box, and every element after it on the page is placed against a size the reader never sees. It cannot go in a paragraph, in a table cell, or in a flex row beside a label without pushing something out of place.
So the overlap is real layout, a negative margin in React, and a render object of its own in Flutter, where EdgeInsets and Flex.spacing both assert they are not negative. Five 32px items at 10px of overlap measure exactly:
| direction | box |
|---|---|
horizontal | 120 × 32 |
vertical | 32 × 120 |
diagonal | 120 × 72 |
The last row is the one worth reading twice. A flow only overlaps on the axis it flows along, so diagonal flows across like horizontal does and takes its vertical step per item instead, one fixed offset in a row would put every item at the same height and the fan would be a row.
Which is also why diagonal is a fan rather than a true 45°: the horizontal advance is item width − overlap, and a component that takes arbitrary children does not know how wide they are. drop is the vertical step, stated separately, and the two are independent on purpose.
direction is responsive, so a pile can run across on a laptop and down on a phone. 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.
Examples
max, total and overflow
max is how many are drawn; total is how many there are, for when the pile was handed only the first few. overflow is given the difference and draws the last item.
A function rather than a node, and that is the whole point: the number is the item. A node would have to be given a count it has no way to work out, and would then be wrong every time the list changed.
front, scaleStep and opacityStep
front says which end of the list is on top. last is what the DOM does on its own and what a row of faces wants, the newest arrival in front. first is what a deck of cards is: the top card is the one you read first.
scaleStep and opacityStep compound away from whichever end is in front, so the front item is always at full strength and turning front round does not also mean turning these round. They are applied at paint time, so an item that recedes still takes the room it took before and the step stays even.
ring
Two shapes of similar tone laid over each other have no boundary between them at all and the pile reads as one smeared shape. The hairline is the page's own surface colour, so it reads as the hole the near item is cut out of rather than as a line around anything. A translucent line would not help, because what is behind it is the other item.
It lands on the element you passed, so it takes that element's shape. Wrap an avatar in something square and the ring is square; there is no way for a component that accepts arbitrary children to know better.
It takes a BorderRadius rather than a bool, which is the one place this widget diverges from the React build. There a ring is a box shadow and CSS gives it the element's own border-radius for nothing; here nothing can read a child's shape, so the shape has to be said.
Coming from PlAvatarGroup
PlAvatarGroup was this component with one kind of child written into it, and it is gone. A row of faces is one arrangement of a pile, not a component of its own.
What moves:
PlAvatarGroup | PlStack |
|---|---|
<PlAvatarGroup> | <PlStack> |
max, total, overlap | unchanged |
the +n avatar, drawn for you | overflow={(n) => <PlAvatar initials={\+${n}`} />}` |
size, color set once for every avatar | a PlassProvider around the stack, or the prop on each avatar |
shape, variant, elevation set once | the prop on each avatar |
| a ring, always | ring |
The group context is the part that is genuinely lost, and it could not be kept: a pile that accepts arbitrary children has no way to know one of them is an avatar. size and color are axes an application already sets once. Put a PlassProvider (Flutter: a PlassTheme) around the stack. The other three were never application-wide axes, and they belong on the avatar.
What is gained is everything the group could not do: any child at all, three directions, a stacking order you can state, depth, and an overflow item you draw yourself.
Accessibility
- The stack adds no role and no label. It is a
<div>around content that already says what it is. Pass anaria-labelwhen a row of faces is standing in for a set that nothing beside it names. - The items keep their own elements, their own semantics and their own focus order. Nothing is cloned and nothing is replaced.
- The stacking order is
z-index, so it changes what is painted on top and what a pointer lands on. It does not change the reading order, which stays the order you wrote, which is what you want: a screen reader should read a set in the order it was given, whichever face happens to be in front.
semanticLabelnames the pile, and the items keep their own nodes under it.- Hit testing runs front to back, which is the reverse of the paint order: the item a reader can see at a point is the one their finger lands on.
- The stacking order changes painting and hit testing only. The semantics order stays the order you wrote.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
ring as a bool | ring as a BorderRadius? | CSS gives a ring the element's own border-radius for nothing; nothing here can read a child's shape, so it has to be said. |
| a negative margin | a render object of its own | EdgeInsets asserts it is non-negative and so does Flex.spacing, so the only place child sizes are known is a layout of our own. It reports the same box either way. |
overflow: (n) => ReactNode | overflow: Widget Function(int)? | The same function, in each framework's own spelling. |
overlap, drop as a length or a number | double? in logical pixels | There is no CSS length to write here. |
aria-label | semanticLabel | Flutter's name. |
className, style | — | There is no class list and no style attribute to pass through. |