Skip to content

feat: implement Toolbar component - #5043

Open
k0ndee wants to merge 7 commits into
callstack:mainfrom
k0ndee:@k0ndee/toolbar
Open

feat: implement Toolbar component#5043
k0ndee wants to merge 7 commits into
callstack:mainfrom
k0ndee:@k0ndee/toolbar

Conversation

@k0ndee

@k0ndee k0ndee commented Aug 11, 2026

Copy link
Copy Markdown

Motivation

Introduce a new Toolbar component implementing the Material Design 3 toolbars spec. Reuse theme tokens (shape, color roles, elevation) and follow the same component-tokens pattern as FAB / Checkbox.

Spec re-check (M3 toolbars)

Re-checked the M3 toolbars specs:

  • Two variants: floating (self-positioned pill, corner.full, elevation level 3) and docked (full-width bar pinned to the bottom edge, corner.none, no elevation, extends into safe-area insets).
  • floating supports horizontal/vertical orientation; docked is always horizontal per spec.
  • Default (standard) colors: container/unselected-button surfaceContainer, icon/label onSurfaceVariant, selected button secondaryContainer with onSecondaryFixedVariant (light) / onSecondaryContainer (dark) icon.
  • vibrant colorScheme: container/unselected-button primaryContainer, icon/label onPrimaryFixedVariant (light) / onPrimaryContainer (dark), selected button falls back to surfaceContainer with onSurface icon.
  • Direct IconButton/Button children are auto-recolored to match colorScheme unless they already set their own color (a mode on either opts them out in favor of their own mode-based coloring).

Changes

Toolbar / tokens / utils

  • New src/components/Toolbar/{Toolbar.tsx,tokens.ts,utils.ts}: variant (floating/docked), orientation (horizontal/vertical, floating-only), colorScheme (standard/vibrant), containerColor override, style/contentContainerStyle, testID, aria-label, theme, ref
  • Container shape/elevation/spacing resolved from ToolbarTokens; docked extends into safe-area insets via margin outside Surface's box, keeping the 64dp icon band untouched
  • withToolbarChildColors auto-recolors mode-less IconButton/Button children per colorScheme, without touching children that already set their own color/mode
  • Exported from src/index.tsx

Example / docs / tests

  • Example screen (ToolbarExample.tsx) covering both variants, both orientations, both color schemes, over a scrollable list
  • Docs page + prop table + theme colors table; screenshots for floating (horizontal/vertical) and docked, each in standard/vibrant
  • Unit tests (Toolbar.test.tsx) covering shape/elevation per variant, color resolution across light/dark themes for both color schemes, and child auto-recoloring behavior

Scope note

Per Satyajit Sahoo's recommendation, this implementation is split into a series of PRs for easier review, rather than landing as one large change. This PR is PR 1 of the series:

  • PR 1 (this PR) — Floating + docked variant, static (without animation, no FAB pairing), colorScheme standard/vibrant. Component, tokens, tests, docs, example.
  • PR 2 — Show/hide animation (offscreen slide + spring), generic scroll shared-value primitive.
  • PR 3 — FAB pairing, FAB/key-action collapse on scroll.
  • PR 4 — Trailing-edge overflow menu.

Related issue

Related to #4988

Test plan

  • yarn typecheck / yarn lint / Toolbar unit tests
  • Full unit test suite green
  • Manually verified in the example app: floating (horizontal/vertical) and docked, standard/vibrant, over scrolling content, light and dark themes

Visual verification

standard vibrant
floating, horizontal floating-h-standard floating-h-vibrant
floating, vertical floating-v-standard floating-v-vibrant
docked docked-standard docked-vibrant

Docs screenshots: docs/public/screenshots/toolbar_*.png.

@k0ndee k0ndee changed the title @k0ndee/toolbar feat: implement Toolbar component Aug 11, 2026

@MikitasK MikitasK 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.

nice work! 👍 overall, this looks like a solid foundation for the new Toolbar components
just a few points to address before merge:

Comment thread src/components/Toolbar/tokens.ts Outdated
Comment on lines +53 to +78
const standardColors = {
container: { light: 'surfaceContainer', dark: 'surfaceContainer' },
buttonContainer: { light: 'surfaceContainer', dark: 'surfaceContainer' },
selectedButtonContainer: {
light: 'secondaryContainer',
dark: 'secondaryContainer',
},
icon: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' },
selectedIcon: {
light: 'onSecondaryFixedVariant',
dark: 'onSecondaryContainer',
},
label: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' },
} as const satisfies Record<string, ToneRole>;

const vibrantColors = {
container: { light: 'primaryContainer', dark: 'primaryContainer' },
buttonContainer: { light: 'primaryContainer', dark: 'primaryContainer' },
selectedButtonContainer: {
light: 'surfaceContainer',
dark: 'surfaceContainer',
},
icon: { light: 'onPrimaryFixedVariant', dark: 'onPrimaryContainer' },
selectedIcon: { light: 'onSurface', dark: 'onSurface' },
label: { light: 'onPrimaryFixedVariant', dark: 'onPrimaryContainer' },
} as const satisfies Record<string, ToneRole>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could we use color roles from M3 spec directly?
standard buttons should use primary, selected buttons should use onSecondaryContainer, vibrant buttons should use onPrimaryContainer.
using FixedVariant roles may also produce incorrect colors with custom themes

Suggested change
const standardColors = {
container: { light: 'surfaceContainer', dark: 'surfaceContainer' },
buttonContainer: { light: 'surfaceContainer', dark: 'surfaceContainer' },
selectedButtonContainer: {
light: 'secondaryContainer',
dark: 'secondaryContainer',
},
icon: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' },
selectedIcon: {
light: 'onSecondaryFixedVariant',
dark: 'onSecondaryContainer',
},
label: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' },
} as const satisfies Record<string, ToneRole>;
const vibrantColors = {
container: { light: 'primaryContainer', dark: 'primaryContainer' },
buttonContainer: { light: 'primaryContainer', dark: 'primaryContainer' },
selectedButtonContainer: {
light: 'surfaceContainer',
dark: 'surfaceContainer',
},
icon: { light: 'onPrimaryFixedVariant', dark: 'onPrimaryContainer' },
selectedIcon: { light: 'onSurface', dark: 'onSurface' },
label: { light: 'onPrimaryFixedVariant', dark: 'onPrimaryContainer' },
} as const satisfies Record<string, ToneRole>;
const standardColors = {
container: { light: 'surfaceContainer', dark: 'surfaceContainer' },
buttonContainer: { light: 'surfaceContainer', dark: 'surfaceContainer' },
selectedButtonContainer: {
light: 'secondaryContainer',
dark: 'secondaryContainer',
},
icon: { light: 'primary', dark: 'primary' },
selectedIcon: {
light: 'onSecondaryContainer',
dark: 'onSecondaryContainer',
},
label: { light: 'primary', dark: 'primary' },
} as const satisfies Record<string, ToneRole>;
const vibrantColors = {
container: { light: 'primaryContainer', dark: 'primaryContainer' },
buttonContainer: { light: 'primaryContainer', dark: 'primaryContainer' },
selectedButtonContainer: {
light: 'surfaceContainer',
dark: 'surfaceContainer',
},
icon: { light: 'onPrimaryContainer', dark: 'onPrimaryContainer' },
selectedIcon: { light: 'onSurface', dark: 'onSurface' },
label: { light: 'onPrimaryContainer', dark: 'onPrimaryContainer' },
} as const satisfies Record<string, ToneRole>;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The spec for Toolbar tells that the standard colors should be:
icon: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' },
label: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' },

image

The vibrant colors according to spec are:
icon: { light: 'onPrimaryContainer', dark: 'onPrimaryContainer' },
label: { light: 'onPrimaryContainer', dark: 'onPrimaryContainer' },

image

I was confused about this, because these colors, like You commented are not the primary colors for Buttons and IconButtons according to the current spec for Buttons, but the Toolbar has it's own spec for included actions, that is why I used these tokens.

Now that the fixedVariants are removed, I can see that the light and dark are using the same roles for colors. I am thinking that I should remove the light and dark fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

you’re right, thanks for clarifying 🙏 toolbar-specific spec uses onSurfaceVariant for standard actions & onPrimaryContainer for vibrant actions. removing light and dark fields totally makes sense - these semantic roles already adapt to the theme 👌

Comment thread src/components/Toolbar/utils.ts Outdated
colorScheme,
selected: child.props.selected ?? false,
});
return React.cloneElement(child, { iconColor, containerColor });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what about selected & disabled icon buttons? explicit containerColor prevents IconButton from applying its normal disabled container treatment. could we use tonal mode for selected buttons?

Suggested change
return React.cloneElement(child, { iconColor, containerColor });
return React.cloneElement(child, {
iconColor,
containerColor,
...(child.props.selected ? { mode: 'contained-tonal' } : null),
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That's a good catch. Fixed it in 6a695d7

Comment thread src/components/Toolbar/Toolbar.tsx Outdated
// jumps position (e.g. bottom-center to a vertical trailing edge),
// avoiding a stale shadow "ghost" that `Surface`'s iOS shadow can
// leave at the old frame when an existing view is resized in place.
key={isVertical ? 'vertical' : 'horizontal'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could we avoid changing key? any other options?
this approach implies that toolbar & its children remounts when orientation changes. it might cause loosing of child state & focus

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Without remount, when switching from floating vertical to horizontal on iOS there is this bug that the inner Animated.View of SurfaceIOS inflated. See screenshots below:

Simulator Screenshot - iPhone 17 - 2026-08-12 at 13 19 11 Simulator Screenshot - iPhone 17 - 2026-08-12 at 13 19 32

I can't figure out why that is happening. On the first mount of floating horizontal Toolbar it looks fine, but when I switch between orientation that inflated pill is appearing. I will do some more debugging and try to find a more sutiable solution for that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maybe we can extract pill background into a separate component & key only that component by orientation while keeping the toolbar content outside it?
I suppose, this should reset SurfaceIOS without remounting the buttons & losing their state or focus

<View style={pillStyle}>
  <PillBackground
    key={isVertical ? 'vertical' : 'horizontal'}
    elevation={elevation}
  />
  <View role="toolbar" style={contentStyle}>
    {children}
  </View>
</View>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I found a better solution. I moved the height and width control to the inner View inside Surface. Surface requires children prop so I would have to pass a {null}, which is I think is not an elegant approach here. With the latest commit I uploaded we keep the same code structure, where Surface wraps the inner View.

<Surface
      ref={isDocked ? undefined : ref}
      elevation={elevation}
      style={[
        {
          backgroundColor,
          borderRadius,
        },
        isDocked && styles.dockedFill,
        styles.content,
        !isDocked && style,
      ]}
      testID={testID}
    >
      <View
        role="toolbar"
        aria-label={ariaLabel}
        testID={`${testID}-content`}
        style={[
          styles.content,
          isVertical ? styles.column : styles.row,
          { ...contentPadding, gap },
          dockedInsetMargin,
          // Cross-axis thickness is the spec default (see `thickness`
          // above). Deliberately set here rather than on `Surface` (which
          // wraps this `View` with no size of its own, so it just hugs
          // it) — giving `Surface` an explicit width/height that flips
          // between renders is what previously left a stale shadow
          // "ghost" on iOS when `floating`'s `orientation` changed axis;
          // that no longer happens with the fixed dimension living here
          // instead.
          isDocked && { height: thickness },
          !isDocked &&
            (isVertical ? { width: thickness } : { height: thickness }),
          contentContainerStyle,
        ]}
      >
        {withToolbarChildColors({ children, theme, colorScheme })}
      </View>
    </Surface>

@MikitasK MikitasK 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.

LGTM 👍

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants