Skip to content

feat(theme): add semantic container color prop to Surface - #5121

Open
hristototov wants to merge 4 commits into
mainfrom
hristototov/surface-semantic-container-color
Open

hristototov wants to merge 4 commits into
mainfrom
hristototov/surface-semantic-container-color

Conversation

@hristototov

@hristototov hristototov commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Surface takes its background from elevation, so a component can't use an MD3 surface role without passing a raw color. The new container prop takes a surface role and resolves it from the theme. elevation then only sets the shadow.

Menu, Banner and Searchbar now use it. With LightTheme and DarkTheme they look the same. With the dynamic themes on Android 12+, Menu and Banner use the system surface colors.

Related issue

Fixes #5119

Screenshots / Videos

light-mode.mov
dark-mode.mov

Test plan

  1. Surface example, light and dark: every "Semantic container colors" tile is visible.
  2. Banner example: "Set custom theme" shows light text on a dark background.

@hristototov
hristototov requested review from JKobrynski, adrcotfas and satya164 and removed request for JKobrynski and satya164 September 14, 2026 12:29

@JKobrynski JKobrynski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, @satya164

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…into hristototov/surface-semantic-container-color
@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

iOS

Banner text is unreadable with the custom theme in the Banner example. (via Claude, verify if it makes sense)

  1. Open the Banner example.
  2. Press "Set custom theme".

What happens: the text turns near white (onSurface: Palette.tertiary100) but the background stays the light surfaceContainerLow. The example overrides colors.elevation.level1 for the background, and Banner no longer reads it.

What should happen: the banner shows the dark tertiary background with light text, like on main. Change the example to override surfaceContainerLow instead of elevation.level1.

iOS, Banner example with custom theme: white text on light background

Surface example: role labels break mid-word and two tiles are invisible. (via Claude, verify if it makes sense)

  1. Open the Surface example.
  2. Scroll the "Semantic container colors" row.

What happens: surfaceContainerLowest and surfaceContainerLow wrap as "surfaceContainerLo / west" and "surfaceContainerLo / w" in the 120 pt tiles. The surface and surfaceBright tiles have the same color as the screen background, so they look like plain text with no tile.

What should happen: each label fits on its own line and each tile is visible against the screen background, for example with a border or mode="elevated".

iOS, Surface example: surface and surfaceBright tiles blend into the screen
iOS, Surface example: labels wrap mid-word

Android

Banner text is unreadable with the custom theme in the Banner example. (via Claude, verify if it makes sense)

Same steps and cause as on iOS.

Android, PR: custom theme, white text on light background
Android, main: custom theme, tertiary background

With the dynamic theme, Banner and Menu change color compared to main. (via Claude, verify if it makes sense)

  1. Use the example app on Android 12 or newer, where it uses DynamicLightTheme by default.
  2. Open the Banner example.

What happens: the background now uses the wallpaper based system_surface_container_* colors. On main it used colors.elevation.level1 and level2, which DynamicLightTheme and DynamicDarkTheme keep from the static theme. The color change itself is fine, but the migration guide says "the rendered color is unchanged", which is not true for the dynamic themes.

What should happen: the migration guide says that the color changes when you use the dynamic themes on Android.

Android, PR: Banner with dynamic theme
Android, main: Banner with dynamic theme

Web

Banner text is unreadable with the custom theme in the Banner example. (via Claude, verify if it makes sense)

Same steps and cause as on iOS.

Web, Banner example with custom theme: white text on light background

(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

See the inline comments. (via Claude, verify if it makes sense)

Comment thread src/components/Banner.tsx
{...rest}
style={[surfaceStyle, style]}
theme={theme}
container="surfaceContainerLow"

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.

This breaks the "Set custom theme" action in the Banner example on iOS, Android and web. example/src/Examples/BannerExample.tsx sets the custom background through colors.elevation.level1 and the text through onSurface: Palette.tertiary100. Banner no longer reads elevation.level1, so the near white text now sits on the light surfaceContainerLow background.

iOS, Banner example with custom theme

Update the example to override the role Banner now reads:

  const customTheme = {
    ...defaultTheme,
    colors: {
      ...defaultTheme.colors,
      onSurface: Palette.tertiary100,
      surfaceContainerLow: Palette.tertiary50,
      primary: Palette.tertiary10,
    },
  };

Any app that customized elevation.level1 to style Banner (or elevation.level2 for Menu) hits the same problem, so the migration guide should also say which role to override. (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

* variant-driven fills outside the surface family should keep resolving
* their own tokens and pass the result via `backgroundColor`.
*/
container?: SurfaceRole;

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 existing backgroundColor prop already does this. backgroundColor={theme.colors.surfaceContainer} resolves from the theme, follows theme changes, and makes elevation control only the shadow, because it also wins over the elevation color. Searchbar (backgroundColor={theme.colors.surfaceContainerHigh}) and Dialog (surfaceContainerHigh) already use that pattern. With this PR, the library has two ways to set a surface role, and the new JSDoc tells contributors to prefer container, which those two components do not follow.

The name is also unclear. container="surface" or container="inverseSurface" does not name a container, and "container" in this library usually means a wrapper element (contentContainerStyle, styles.container).

Suggested fix: drop container and SurfaceRole, and pass backgroundColor={theme.colors.surfaceContainer} in Menu and backgroundColor={theme.colors.surfaceContainerLow} in Banner. The behavior stays the same and the public API does not grow. If you keep the prop, migrate Searchbar and Dialog to it so there is one pattern. (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Kept container so components can name an MD3 role instead of resolving a raw color, and moved Searchbar to it. I left Dialog as is, because it sets its color through Modal's public contentBackgroundColor and moving it would mean adding a new Modal prop.

Comment thread src/components/Surface.tsx Outdated
Comment on lines +117 to +122
*
* Prefer `container` when the fill is an MD3 surface-family role (as
* component specs define it, e.g. menus use `surfaceContainer`). Use
* `backgroundColor` only for raw, non-semantic colors. Components with
* variant-driven fills outside the surface family should keep resolving
* their own tokens and pass the result via `backgroundColor`.

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.

If the prop stays, this paragraph is guidance for library contributors, not for users of Surface. It shows up in the public API docs. Consider removing it.

Suggested change
*
* Prefer `container` when the fill is an MD3 surface-family role (as
* component specs define it, e.g. menus use `surfaceContainer`). Use
* `backgroundColor` only for raw, non-semantic colors. Components with
* variant-driven fills outside the surface family should keep resolving
* their own tokens and pass the result via `backgroundColor`.

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment thread src/components/Surface.tsx Outdated
Comment on lines +233 to +236
const backgroundColor =
customBackgroundColor ?? colors.elevation?.[`level${elevation}`];
customBackgroundColor ??
(container != null ? colors[container] : undefined) ??
colors.elevation?.[`level${elevation}`];

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.

container is either a role or undefined, and colors[container] is always a color, so the : undefined) ?? step is not needed. A plain ternary reads easier:

Suggested change
const backgroundColor =
customBackgroundColor ?? colors.elevation?.[`level${elevation}`];
customBackgroundColor ??
(container != null ? colors[container] : undefined) ??
colors.elevation?.[`level${elevation}`];
const backgroundColor =
customBackgroundColor ??
(container ? colors[container] : colors.elevation?.[`level${elevation}`]);

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment thread docs/6.x/docs/guides/migration.md Outdated

### Banner

The banner background is now always `theme.colors.surfaceContainerLow` and no longer depends on the `elevation` prop, which only controls the shadow. At the default `elevation` of `1` the rendered color is unchanged.

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 rendered color is unchanged" is true for LightTheme and DarkTheme only. DynamicLightTheme and DynamicDarkTheme on Android 12 and newer replace surfaceContainer* with PlatformColor values but keep the static elevation colors. So Banner and Menu change from the static tint to the wallpaper based color there. The example app uses the dynamic theme by default on Android:

main this PR
Android, main Android, PR

The guide should also tell users which role to override now, since a custom elevation.level1 no longer applies:

Suggested change
The banner background is now always `theme.colors.surfaceContainerLow` and no longer depends on the `elevation` prop, which only controls the shadow. At the default `elevation` of `1` the rendered color is unchanged.
The banner background is now always `theme.colors.surfaceContainerLow` and no longer depends on the `elevation` prop, which only controls the shadow. If you customized `theme.colors.elevation.level1` to change the banner background, customize `theme.colors.surfaceContainerLow` instead. With `LightTheme` and `DarkTheme`, the rendered color at the default `elevation` of `1` is unchanged. With `DynamicLightTheme` and `DynamicDarkTheme` on Android 12 and newer, the banner now uses the system surface color.

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment thread docs/6.x/docs/guides/migration.md Outdated

### Menu

The menu background is now always `theme.colors.surfaceContainer`, following the Material Design 3 spec, and no longer depends on the `elevation` prop, which only controls the shadow. At the default `elevation` of `2` the rendered color is unchanged.

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.

Same as for Banner above:

Suggested change
The menu background is now always `theme.colors.surfaceContainer`, following the Material Design 3 spec, and no longer depends on the `elevation` prop, which only controls the shadow. At the default `elevation` of `2` the rendered color is unchanged.
The menu background is now always `theme.colors.surfaceContainer`, following the Material Design 3 spec, and no longer depends on the `elevation` prop, which only controls the shadow. If you customized `theme.colors.elevation.level2` to change the menu background, customize `theme.colors.surfaceContainer` instead. With `LightTheme` and `DarkTheme`, the rendered color at the default `elevation` of `2` is unchanged. With `DynamicLightTheme` and `DynamicDarkTheme` on Android 12 and newer, the menu now uses the system surface color.

(via Claude, verify if it makes sense)

Comment thread docs/6.x/docs/guides/migration.md Outdated
- The `pointerEvents` prop is no longer supported as it's deprecated in React Native Web. You can specify `pointerEvents` in the `style` prop instead.
- The `overflow: 'hidden'` style is no longer supported in `style` as it can clip shadows. You can nest a `View` inside the `Surface` and apply `overflow: 'hidden'` to that instead.
- The default `testID` for `Surface` was removed. You can specify a `testID` explicitly if you need it.
- A new `container` prop sets the background to a semantic surface-family color role from the theme (e.g. `container="surfaceContainerLow"`). When `container` is set, `elevation` only controls the shadow. Precedence: `backgroundColor` > `container` > the color derived from `elevation`.

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.

This bullet sits between the style bullet and the "e.g.:" diff that shows how to migrate style, so the example now reads as if it belongs to container. It also describes a new feature, not a step users need to take. The Surface API docs already cover it, so I would remove the bullet:

Suggested change
- A new `container` prop sets the background to a semantic surface-family color role from the theme (e.g. `container="surfaceContainerLow"`). When `container` is set, `elevation` only controls the shadow. Precedence: `backgroundColor` > `container` > the color derived from `elevation`.

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment on lines +117 to +123
<Surface
key={role}
style={styles.surface}
borderRadius={8}
mode="flat"
container={role}
>

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.

Two problems with this row on iOS and Android:

  1. The surface and surfaceBright tiles have the same color as the screen, so they look like plain text with no tile.
  2. In the 120 pt tiles, surfaceContainerLowest and surfaceContainerLow wrap mid-word ("surfaceContainerLo / west").

iOS, tiles blend into the screen
iOS, labels wrap mid-word

An elevated tile makes every role visible, and it also shows that elevation only sets the shadow here. For the wrapping, use wider tiles for this row or a smaller text variant.

Suggested change
<Surface
key={role}
style={styles.surface}
borderRadius={8}
mode="flat"
container={role}
>
<Surface
key={role}
style={styles.surface}
borderRadius={8}
elevation={1}
container={role}
>

(via Claude, verify if it makes sense)

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 elevated tile does not fix this in dark theme, because surface and surfaceDim are both neutral6 there and the shadow does not show on the dark screen (screenshots). A hairline outlineVariant border shows every tile in light and dark theme:

Suggested change
<Surface
key={role}
style={styles.surface}
borderRadius={8}
mode="flat"
container={role}
>
<Surface
key={role}
style={[
styles.surface,
{
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.outlineVariant,
},
]}
borderRadius={8}
mode="flat"
container={role}
>

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

iOS

Surface example, dark theme: the surface and surfaceDim tiles are invisible, also with the elevated tile suggested earlier. (via Claude, verify if it makes sense)

  1. Turn on "Dark Theme" in the drawer.
  2. Open the Surface example and look at the "Semantic container colors" row.

What happens: in DarkTheme, surface and surfaceDim both use neutral6, the same color as the screen. With elevation={1} and mode="elevated" (the earlier suggestion), the shadow does not show on the dark screen, so both tiles still look like plain text.

What should happen: every tile is visible in light and dark theme. A hairline outlineVariant border does this (see the web screenshot below).

iOS, dark theme, elevated tiles: surface and surfaceDim still blend in

Android

Surface example, dark theme: the surface and surfaceDim tiles are invisible. (via Claude, verify if it makes sense)

Same steps and cause as on iOS.

Android, dark theme: surface and surfaceDim tiles blend in

Web

Surface example, dark theme: the surface and surfaceDim tiles are invisible. (via Claude, verify if it makes sense)

Same steps and cause as on iOS.

Web, dark theme: surface and surfaceDim tiles blend in
Web, dark theme, with a hairline outlineVariant border: every tile is visible

(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

  • docs/6.x/docs/guides/theming.mdx (line 109) still names menus as an example of surfaces tinted by theme.colors.elevation. With this PR, Menu and Banner take their background from surfaceContainer and surfaceContainerLow, so a reader who changes elevation.level2 to restyle menus sees no effect. Drop "or menus" there, or add that Menu and Banner use the surfaceContainer* roles. (via Claude, verify if it makes sense)

Comment thread src/components/Surface.tsx Outdated
Comment on lines +108 to +110
* Semantic color role of the Surface background, resolved from the
* theme, e.g. `container="surfaceContainerLow"` renders with
* `theme.colors.surfaceContainerLow`.

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 generated docs page shows the type of this prop as SurfaceRole only, so readers cannot see which values it accepts. The values are only listed in src/theme/types/color.ts, which the docs do not show. List them here:

Suggested change
* Semantic color role of the Surface background, resolved from the
* theme, e.g. `container="surfaceContainerLow"` renders with
* `theme.colors.surfaceContainerLow`.
* Semantic color role of the Surface background, resolved from the
* theme, e.g. `container="surfaceContainerLow"` renders with
* `theme.colors.surfaceContainerLow`. Accepted roles: `surface`,
* `surfaceDim`, `surfaceBright`, `surfaceVariant`,
* `surfaceContainerLowest`, `surfaceContainerLow`, `surfaceContainer`,
* `surfaceContainerHigh`, `surfaceContainerHighest` and `inverseSurface`.

(via Claude, verify if it makes sense)

Hristo Totov added 2 commits October 24, 2026 11:43
@hristototov

Copy link
Copy Markdown
Collaborator Author

Review by Claude

  • docs/6.x/docs/guides/theming.mdx (line 109) still names menus as an example of surfaces tinted by theme.colors.elevation. With this PR, Menu and Banner take their background from surfaceContainer and surfaceContainerLow, so a reader who changes elevation.level2 to restyle menus sees no effect. Drop "or menus" there, or add that Menu and Banner use the surfaceContainer* roles. (via Claude, verify if it makes sense)

Updated theming.mdx: the elevation note now names only app bars, and says that menus and banners use the surfaceContainer and surfaceContainerLow roles instead.

@hristototov
hristototov requested a review from satya164 October 24, 2026 08:54

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.

Surface: first-class semantic container color API independent of elevation

4 participants