Repository navigation
refactor(snackbar)!: modernize layout and action API - #5135
grzegorzkruk wants to merge 1 commit into
Conversation
Move the Snackbar to its own directory and align it with the MD3 spec.
- The message is passed via the `message` prop instead of `children`, which
removes the arbitrary child composition the layout had to account for.
- `action` takes a typed `SnackbarAction` instead of every `Button` prop, and
dismisses the Snackbar on its own.
- `contentStyle` becomes `messageStyle`, applied to the message text.
- The Snackbar renders in a `Portal` by default, so it overlays all other
content without the consumer wrapping it. Set `portal={false}` to opt out.
- Layout, color roles and motion come from `tokens.ts` and the theme: 48dp
minimum height, 600dp max width, 16/14dp message padding, `extraSmall`
shape via `resolveCornerRadius`, and `inverseSurface` / `inverseOnSurface`
/ `inversePrimary`.
- An action which doesn't fit next to the message wraps onto its own trailing
line, as the spec's longer-action configuration asks for.
- The enter/exit animations use the theme motion tokens, and the auto-dismiss
timer starts once the enter animation has settled.
Refs callstack#4932
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Found potential problems with the pull request:
|
There was a problem hiding this comment.
Review by Claude
From the code, a Snackbar rendered inside a React Native Modal now mounts in the root Portal host, which sits behind the native modal. If that holds, the migration guide could mention portal={false} for that case. (via Claude, verify if it makes sense)
| width: '100%', | ||
| maxWidth: containerMaxWidth, | ||
| flexDirection: 'row', | ||
| flexWrap: 'wrap', | ||
| // Lines are trailing-aligned, so an action wrapped onto its own line sits | ||
| // at the trailing edge below the message. | ||
| justifyContent: 'flex-end', | ||
| margin: screenMargin, |
There was a problem hiding this comment.
width: '100%' plus margin: screenMargin removes the side margin. The width resolves to the full wrapper width, the 8dp margins hang outside it, and alignItems: 'center' centers the overflow. On a phone the bar touches both screen edges on iOS, Android and web (web: left: 0, right: 375 at 375px with a computed margin of 8px). On main the container stretched and kept the 8dp gap.
Moving the side margin to the wrapper padding fixes it:
// wrapper (line 377)
paddingHorizontal: Math.max(left, right) + screenMargin,
// container
marginVertical: screenMargin,
``` (via Claude, verify if it makes sense)| /** | ||
| * Share of the container the message starts with. An action which doesn't fit | ||
| * in what's left — a long action label on a narrow screen — moves onto its own | ||
| * line below the message, as the "two lines with longer action" configuration | ||
| * of the spec asks for. | ||
| */ | ||
| messageInlineBasis: '60%' as const, |
There was a problem hiding this comment.
The wrap decision depends on the 60% share, not on whether the action fits. A short message with a medium action wraps without need. On an iPhone 17 (402pt), "Single-line snackbar" with "Toggle theme" and the close icon needs about 320pt, but the actions get only about 130pt after the 60% basis, so they move to a second line.
The spec puts the action below only when it is too long to sit next to the message. Material Components Android does this in SnackbarContentLayout: it stacks the action only when the message takes more than one line and the action is wider than maxInlineActionWidth (128dp). A single-line message keeps the action inline. A flex basis alone cannot express that rule, so this probably needs the action width (onLayout) and the message line count (onTextLayout), or a flag the consumer sets. (via Claude, verify if it makes sense)
| onPress={(event) => { | ||
| action.onPress?.(event); | ||
| onDismiss(); | ||
| }} |
There was a problem hiding this comment.
The action stays pressable during the 200ms exit animation, so a double tap calls action.onPress and onDismiss twice. On web, double-clicking Action in the example toggles the theme twice, and the Snackbar stays visible because the second onDismiss toggles showSnackbar back on. For an "Undo" action this runs the undo twice. Main has the same gap, but the new doc on SnackbarAction.onPress now promises that the Snackbar dismisses itself after the press.
Ignore presses once visible is false. That needs visible here, which is easier if ActionButton is inlined. It is a one-off helper used once:
onPress={(event) => {
if (!visible) {
return;
}
action.onPress?.(event);
onDismiss();
}}The close icon has the same problem with onIconPress. (via Claude, verify if it makes sense)
|
|
||
| const snackbar = ( | ||
| <View | ||
| pointerEvents="box-none" |
There was a problem hiding this comment.
styles.wrapper already sets pointerEvents: 'box-none', so this prop does nothing extra. On web, react-native-web logs props.pointerEvents is deprecated. Use style.pointerEvents for it. The 6.x migration guide also tells users to move pointerEvents into style.
| pointerEvents="box-none" | |
| ``` (via Claude, verify if it makes sense) |
|
|
||
| const animatedStyle = useAnimatedStyle(() => ({ | ||
| opacity: opacity.value, | ||
| transform: [{ scale: interpolate(opacity.value, [0, 1], [0.9, 1]) }], |
There was a problem hiding this comment.
The exit animation now also scales the bar down to 0.9. Main kept scale: 1 while hiding (visible ? interpolate(...) : 1), and Material Components Android fades the Snackbar out without scaling. Was the change on purpose? If not:
| transform: [{ scale: interpolate(opacity.value, [0, 1], [0.9, 1]) }], | |
| transform: [ | |
| { scale: visible ? interpolate(opacity.value, [0, 1], [0.9, 1]) : 1 }, | |
| ], | |
| ``` (via Claude, verify if it makes sense) |
| return; | ||
| } | ||
|
|
||
| setMounted(true); |
There was a problem hiding this comment.
setMounted(true) in the effect adds a render that returns null before the Snackbar appears. Main set the state during render (if (visible && hidden) setHidden(false)), so the Snackbar mounted in the same render as visible changed. Keeping that pattern avoids the extra commit:
if (visible && !mounted) {
setMounted(true);
}
``` (via Claude, verify if it makes sense)| onIconPress={ | ||
| showCloseIcon | ||
| ? () => setOptions({ ...options, showSnackbar: false }) | ||
| onDismiss={() => toggleOption('showSnackbar')} |
There was a problem hiding this comment.
onDismiss toggles showSnackbar instead of setting it to false. Any second onDismiss call shows the Snackbar again. The double-click case in the Snackbar comment does exactly that. The timer also calls the onDismiss from the render that finished the enter animation, so toggleOption spreads stale options and reverts switches flipped while the Snackbar is visible. A functional update that sets false avoids both:
| onDismiss={() => toggleOption('showSnackbar')} | |
| onDismiss={() => | |
| setOptions((prev) => ({ ...prev, showSnackbar: false })) | |
| } |
The same applies to onIconPress below. (via Claude, verify if it makes sense)
| The Snackbar now renders in a `Portal` on its own, so it overlays all other | ||
| content. Wrapping it in a `Portal` is no longer needed — and would render it | ||
| twice. Set `portal={false}` to keep it inside the parent instead. |
There was a problem hiding this comment.
Wrapping the Snackbar in a Portal does not render it twice. The inner Portal mounts into the same host, and the outer layer stays empty. I checked this with a quick test: <Portal><Snackbar visible ... /></Portal> inside Portal.Host gives one match for the message. The extra wrapper is only redundant.
| The Snackbar now renders in a `Portal` on its own, so it overlays all other | |
| content. Wrapping it in a `Portal` is no longer needed — and would render it | |
| twice. Set `portal={false}` to keep it inside the parent instead. | |
| The Snackbar now renders in a `Portal` on its own, so it overlays all other | |
| content, and you no longer need to wrap it in a `Portal`. Set | |
| `portal={false}` to keep it inside the parent instead. | |
| ``` (via Claude, verify if it makes sense) |
| const renderSnackbar = ( | ||
| props: Partial<React.ComponentProps<typeof Snackbar>> = {} | ||
| ) => | ||
| render( | ||
| <Portal.Host> | ||
| <Snackbar | ||
| visible | ||
| onDismiss={jest.fn()} | ||
| message="Snackbar content" | ||
| // Keeps the auto-dismiss timer out of the way, so that tests asserting | ||
| // on the rendered output don't dismiss the Snackbar on their own. | ||
| duration={Infinity} | ||
| {...props} | ||
| /> | ||
| </Portal.Host> | ||
| ); |
There was a problem hiding this comment.
Every test renders inside Portal.Host with the default portal, so the new portal prop has no coverage. Please add tests that:
portal={false}renders the Snackbar in place, next to its siblings, and works without aPortalhost.- The default renders it in the host layer above sibling content.
- Setting
visibleback totrueduring the exit animation keeps the Snackbar mounted and starts the timer again. - A second press on the action after it was pressed once does not call
onPressoronDismissagain (with the fix from the Snackbar comment). (via Claude, verify if it makes sense)
| */ | ||
| const sizes = { | ||
| /** Corner radius of the container: `md.sys.shape.corner.extra-small`. */ | ||
| containerShape: 'extraSmall' as ShapeToken, |
There was a problem hiding this comment.
as ShapeToken is a type assertion, and the project rules ask to avoid as. satisfies checks the value and keeps the literal type:
| containerShape: 'extraSmall' as ShapeToken, | |
| containerShape: 'extraSmall' satisfies ShapeToken, | |
| ``` (via Claude, verify if it makes sense) |






Move the Snackbar to its own directory and align it with the MD3 spec.
Refs #4932
Motivation
The Snackbar API is built around arbitrary
childrenand a fullButtonprops object, so the layout cannot follow the Material Design 3 message-and-action model, and consumers have to wrap the component in aPortalthemselves to keep it above other content.Related issue
Fixes #4932
Screenshots / Videos
Checked on the Android example, Snackbar screen:
Test plan
onDismissfrom the action handler.durationisInfinity.