Skip to content

refactor(snackbar)!: modernize layout and action API - #5135

Open
grzegorzkruk wants to merge 1 commit into
callstack:mainfrom
grzegorzkruk:refactor/snackbar-md3
Open

grzegorzkruk wants to merge 1 commit into
callstack:mainfrom
grzegorzkruk:refactor/snackbar-md3

Conversation

@grzegorzkruk

@grzegorzkruk grzegorzkruk commented Oct 1, 2026 •

Copy link
Copy Markdown

Move the Snackbar to its own directory and align it with the MD3 spec.

Refs #4932

Motivation

The Snackbar API is built around arbitrary children and a full Button props object, so the layout cannot follow the Material Design 3 message-and-action model, and consumers have to wrap the component in a Portal themselves to keep it above other content.

Related issue

Fixes #4932

Screenshots / Videos

Checked on the Android example, Snackbar screen:

  • Default: a single-line inverse surface with "Single-line snackbar" and a trailing "Action".
image
  • Longer message, longer action, and close icon: the message wraps to two lines, and "Toggle theme" plus the close icon sit on a trailing line below it.
image
  • With "Render in portal" off, the same bar still pins to the bottom of the screen.
image

Test plan

  • Open the example app and go to Snackbar.
  • Press Show. Confirm a single-line snackbar with the message and an Action button.
  • Press Action. Confirm the snackbar dismisses without calling onDismiss from the action handler.
  • Turn on Longer message and Show again. Confirm the message wraps to a second line.
  • Turn on Longer action. Confirm the action moves onto its own trailing line.
  • Turn on Close icon button and press the icon. Confirm the snackbar dismisses.
  • Turn off Render in portal and Show again. Confirm the snackbar still appears at the bottom of the screen.
  • Leave it visible. Confirm it dismisses on its own after the medium duration, and that it stays up when duration is Infinity.

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>
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

iOS (iPhone 17 simulator)

  1. The Snackbar has no side margin on a phone.
    Steps: open Snackbar, press Show.
    What happens: the bar touches both screen edges. Only the bottom margin is there.
    What should happen: an 8dp gap on the left and right, as on main.
    iOS: Snackbar touches both screen edges
    (via Claude, verify if it makes sense)

  2. A short message with a longer action wraps the action onto a second line, though both fit on one line.
    Steps: turn on Close icon button and Longer action, keep Longer message off, press Show.
    What happens: "Single-line snackbar" sits alone on the first line, and "Toggle theme" plus the close icon move to a second line.
    What should happen: one line. The message and the actions need about 340pt of the 402pt screen.
    iOS: short message with the action wrapped onto a second line
    (via Claude, verify if it makes sense)

Android (Pixel emulator)

  1. Same missing side margin as on iOS.
    Steps: open Snackbar, press Show.
    Android: Snackbar touches both screen edges
    (via Claude, verify if it makes sense)

Web (375px wide viewport)

  1. Same missing side margin. getBoundingClientRect() on the Surface gives left: 0, right: 375 while its computed margin is 8px.
    Web, 375px: Snackbar touches both edges
    (via Claude, verify if it makes sense)

  2. Double-clicking Action keeps the Snackbar on screen and runs action.onPress twice.
    Steps: Show, then double-click Action.
    What happens: the theme toggles twice and the Snackbar stays visible. The second click lands during the exit animation, calls onPress again, and the example's onDismiss toggles showSnackbar back to true.
    What should happen: one onPress call, and the Snackbar hides.
    (via Claude, verify if it makes sense)

@satya164 satya164 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +461 to +468
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,

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

iOS: Snackbar touches both screen edges

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)

Comment on lines +26 to +32
/**
* 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

iOS: short message with the action wrapped onto 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)

Comment on lines +181 to +184
onPress={(event) => {
action.onPress?.(event);
onDismiss();
}}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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]) }],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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')}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
onDismiss={() => toggleOption('showSnackbar')}
onDismiss={() =>
setOptions((prev) => ({ ...prev, showSnackbar: false }))
}

The same applies to onIconPress below. (via Claude, verify if it makes sense)

Comment on lines +313 to +315
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Comment on lines +16 to +31
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>
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a Portal host.
  • The default renders it in the host layer above sibling content.
  • Setting visible back to true during 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 onPress or onDismiss again (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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as ShapeToken is a type assertion, and the project rules ask to avoid as. satisfies checks the value and keeps the literal type:

Suggested change
containerShape: 'extraSmall' as ShapeToken,
containerShape: 'extraSmall' satisfies ShapeToken,
``` (via Claude, verify if it makes sense)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants