Navigation
Floating toolbar
A toolbar that floats over the content, horizontal or vertical, with an optional FAB; it can hide on scroll.
- COMPONENT_SPEC §9
- CSS · custom elements · React
- Open in the playground
Examples
Standard and vibrant
A toolbar has one tab stop: the arrow keys, Home and End move between its items.
The live demo runs with JavaScript. Its code is below.
import { FloatingToolbar, Icon, IconButton } from '@kyralabs/m3exp-web/react';
const FORMAT = [
['format_bold', 'Bold'],
['format_italic', 'Italic'],
['format_underlined', 'Underline'],
] as const;
export default function Horizontal() {
return (
<>
{(['standard', 'vibrant'] as const).map((colorStyle) => (
<FloatingToolbar
key={colorStyle}
aria-label={`Formatting, ${colorStyle}`}
colorStyle={colorStyle}
>
{FORMAT.map(([icon, label]) => (
<IconButton key={icon} aria-label={label}>
<Icon name={icon} />
</IconButton>
))}
</FloatingToolbar>
))}
</>
);
}
<m3e-floating-toolbar expanded label="Formatting, standard">
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Bold">
<span class="m3e-icon" aria-hidden="true">format_bold</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Italic">
<span class="m3e-icon" aria-hidden="true">format_italic</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Underline">
<span class="m3e-icon" aria-hidden="true">format_underlined</span>
</button>
</m3e-floating-toolbar>
<m3e-floating-toolbar expanded label="Formatting, vibrant" color-style="vibrant">
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Bold">
<span class="m3e-icon" aria-hidden="true">format_bold</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Italic">
<span class="m3e-icon" aria-hidden="true">format_italic</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Underline">
<span class="m3e-icon" aria-hidden="true">format_underlined</span>
</button>
</m3e-floating-toolbar>
Run the HTML version (custom elements, no framework).
Vertical
Leading and trailing items show only while the toolbar is expanded.
The live demo runs with JavaScript. Its code is below.
import { FloatingToolbar, Icon, IconButton } from '@kyralabs/m3exp-web/react';
export default function Vertical() {
return (
<FloatingToolbar
aria-label="Editing"
orientation="vertical"
leading={
<IconButton aria-label="Done">
<Icon name="check" />
</IconButton>
}
trailing={
<IconButton aria-label="Favorite">
<Icon name="favorite" />
</IconButton>
}
>
<IconButton aria-label="Bold">
<Icon name="format_bold" />
</IconButton>
<IconButton aria-label="Italic">
<Icon name="format_italic" />
</IconButton>
</FloatingToolbar>
);
}
<m3e-floating-toolbar expanded label="Editing" orientation="vertical">
<button
type="button"
slot="leading"
class="m3e-icon-button m3e-icon-button--standard"
aria-label="Done"
>
<span class="m3e-icon" aria-hidden="true">check</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Bold">
<span class="m3e-icon" aria-hidden="true">format_bold</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Italic">
<span class="m3e-icon" aria-hidden="true">format_italic</span>
</button>
<button
type="button"
slot="trailing"
class="m3e-icon-button m3e-icon-button--standard"
aria-label="Favorite"
>
<span class="m3e-icon" aria-hidden="true">favorite</span>
</button>
</m3e-floating-toolbar>
Run the HTML version (custom elements, no framework).
With a FAB
The FAB toggles the toolbar here; the items collapse into it on the spatial spring.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import { Fab, FloatingToolbar, Icon, IconButton } from '@kyralabs/m3exp-web/react';
export default function WithFab() {
const [expanded, setExpanded] = useState(true);
return (
<FloatingToolbar
aria-label="Actions"
colorStyle="vibrant"
isExpanded={expanded}
onExpandedChange={setExpanded}
fab={
<Fab
icon={<Icon name={expanded ? 'close' : 'add'} />}
aria-label={expanded ? 'Collapse toolbar' : 'Expand toolbar'}
onPress={() => {
setExpanded((e) => !e);
}}
/>
}
>
<IconButton aria-label="Search">
<Icon name="search" />
</IconButton>
<IconButton aria-label="Edit">
<Icon name="edit" />
</IconButton>
<IconButton aria-label="Share">
<Icon name="share" />
</IconButton>
</FloatingToolbar>
);
}
<m3e-floating-toolbar id="actions" expanded label="Actions" color-style="vibrant">
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Search">
<span class="m3e-icon" aria-hidden="true">search</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Edit">
<span class="m3e-icon" aria-hidden="true">edit</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Share">
<span class="m3e-icon" aria-hidden="true">share</span>
</button>
<button type="button" slot="fab" class="m3e-fab" aria-label="Collapse toolbar">
<span class="m3e-icon" aria-hidden="true">close</span>
</button>
</m3e-floating-toolbar>
<script>
const toolbar = document.getElementById('actions');
const fab = toolbar.querySelector('[slot="fab"]');
fab.addEventListener('click', () => {
toolbar.expanded = !toolbar.expanded;
fab.setAttribute('aria-label', toolbar.expanded ? 'Collapse toolbar' : 'Expand toolbar');
fab.querySelector('.m3e-icon').textContent = toolbar.expanded ? 'close' : 'add';
});
</script>
Run the HTML version (custom elements, no framework).
Hiding on scroll
Exit-always: the toolbar leaves when the content scrolls down and returns on any scroll up. collapse collapses it instead.
The live demo runs with JavaScript. Its code is below.
import { useRef } from 'react';
import { FloatingToolbar, Icon, IconButton } from '@kyralabs/m3exp-web/react';
const ROWS = Array.from({ length: 30 }, (_, i) => `Row ${String(i + 1)}`);
export default function Scroll() {
const scroller = useRef<HTMLDivElement>(null);
return (
<div
style={{
position: 'relative',
overflow: 'hidden',
inlineSize: '100%',
maxInlineSize: 'var(--m3e-sys-window-width-medium)',
blockSize: 'var(--m3e-sys-window-height-medium)',
}}
>
<div
ref={scroller}
tabIndex={0}
role="region"
aria-label="Rows, scrollable"
style={{ overflow: 'auto', blockSize: '100%' }}
>
<ul style={{ margin: 0, padding: 'var(--m3e-sys-space-200)' }}>
{ROWS.map((row) => (
<li key={row} style={{ paddingBlock: 'var(--m3e-sys-space-150)' }}>
{row}
</li>
))}
</ul>
</div>
<FloatingToolbar
aria-label="Formatting"
scrollBehavior="exit-always"
scrollTarget={scroller}
style={{
position: 'absolute',
insetBlockEnd: 'var(--m3e-comp-floating-toolbar-defaults-screen-offset)',
insetInline: 0,
inlineSize: 'fit-content',
marginInline: 'auto',
}}
>
<IconButton aria-label="Bold">
<Icon name="format_bold" />
</IconButton>
<IconButton aria-label="Italic">
<Icon name="format_italic" />
</IconButton>
<IconButton aria-label="Underline">
<Icon name="format_underlined" />
</IconButton>
</FloatingToolbar>
</div>
);
}
<div
style="
position: relative;
overflow: hidden;
inline-size: 100%;
max-inline-size: var(--m3e-sys-window-width-medium);
block-size: var(--m3e-sys-window-height-medium);
"
>
<div
id="rows"
tabindex="0"
role="region"
aria-label="Rows, scrollable"
style="overflow: auto; block-size: 100%"
>
<ul style="margin: 0; padding: var(--m3e-sys-space-200)">
<li style="padding-block: var(--m3e-sys-space-150)">Row 1</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 2</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 3</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 4</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 5</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 6</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 7</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 8</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 9</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 10</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 11</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 12</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 13</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 14</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 15</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 16</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 17</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 18</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 19</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 20</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 21</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 22</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 23</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 24</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 25</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 26</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 27</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 28</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 29</li>
<li style="padding-block: var(--m3e-sys-space-150)">Row 30</li>
</ul>
</div>
<div
style="
position: absolute;
inset-block-end: var(--m3e-comp-floating-toolbar-defaults-screen-offset);
inset-inline: 0;
display: flex;
justify-content: center;
"
>
<m3e-floating-toolbar
expanded
label="Formatting"
scroll-behavior="exit-always"
scroll-target="rows"
>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Bold">
<span class="m3e-icon" aria-hidden="true">format_bold</span>
</button>
<button type="button" class="m3e-icon-button m3e-icon-button--standard" aria-label="Italic">
<span class="m3e-icon" aria-hidden="true">format_italic</span>
</button>
<button
type="button"
class="m3e-icon-button m3e-icon-button--standard"
aria-label="Underline"
>
<span class="m3e-icon" aria-hidden="true">format_underlined</span>
</button>
</m3e-floating-toolbar>
</div>
</div>
Run the HTML version (custom elements, no framework).
API
React
<FloatingToolbar>
Floating toolbar (COMPONENT_SPEC §9; Compose HorizontalFloatingToolbar / VerticalFloatingToolbar and their with-FAB overloads): the .m3e-floating-toolbar contract with role="toolbar". The expand/collapse springs, roving focus and scroll behaviors come from m3e-elements' FloatingToolbarLayout, the same code <m3e-floating-toolbar> runs (React Aria's useToolbar keeps every item tabbable and has no Home/End, so the shared roving tabindex is used instead).
| Prop | Type | Default | Description |
|---|---|---|---|
children
|
ReactNode
|
The toolbar items, usually <IconButton>s (each takes a 48dp slot).
|
|
leading
|
ReactNode
|
Items before children that show only while expanded (no effect beside a fab).
|
|
trailing
|
ReactNode
|
Items after children that show only while expanded (no effect beside a fab).
|
|
fab
|
ReactNode
|
A FAB beside the toolbar, usually <Fab>: the toolbar sizes it (80dp collapsed, 56dp
expanded) and colors it to match colorStyle. Collapsed, the whole toolbar folds into it.
|
|
fabPosition
|
'end' | 'start'
|
Where the FAB sits: end (default; bottom when vertical) or start (top).
|
|
orientation
|
"horizontal" | "vertical"
|
horizontal |
Default horizontal.
|
colorStyle
|
"standard" | "vibrant"
|
standard |
Default standard.
|
isExpanded
|
boolean
|
Controlled expanded state. | |
defaultExpanded
|
boolean
|
true |
Uncontrolled initial state. Default true.
|
scrollBehavior
|
"exit-always" | "exitAlways" | "collapse"
|
Default: none (static). 'exitAlways' is deprecated: use 'exit-always'.
|
|
exitDirection
|
"start" | "end" | "bottom" | "top"
|
bottom |
exit-always only. Default bottom (horizontal) / end (vertical).
|
scrollTarget
|
RefObject<HTMLElement | null>
|
The scrolling element the toolbar floats over. Default: the document. | |
aria-label
|
string
|
The toolbar's accessible name (or use aria-labelledby).
|
|
onExpandedChange
|
(expanded: boolean) => void
|
Called when the collapse scroll behavior expands or collapses the toolbar.
|
|
expanded
|
boolean
|
Deprecated:
Since 1.8: use isExpanded. Removed in 2.0.
Controlled expanded state.
|
Also accepts HTML attributes not listed here.
floatingToolbarClassName()
Classes for the .m3e-floating-toolbar root (the m3e-css contract).
Custom elements
<m3e-floating-toolbar>
import '@kyralabs/m3e-elements/floating-toolbar.js';
<m3e-floating-toolbar expanded label="Formatting"> (COMPONENT_SPEC §9; Compose HorizontalFloatingToolbar / VerticalFloatingToolbar). Renders the m3e-css .m3e-floating-toolbar contract inside itself (the host is display: contents) and moves its children into it: children with slot="leading" / slot="trailing" show only while expanded, slot="fab" (a <button class="m3e-fab">) makes the with-FAB toolbar, the rest are the toolbar items (usually .m3e-icon-buttons).
- Attributes:
expanded(reflected; absent = collapsed),orientation(horizontal|vertical),color-style(standard|vibrant),label(the toolbar's accessible name),fab-position(end|start),scroll-behavior(exit-always|collapse),exit-direction(bottom|top|start|end),scroll-target(id of the scrolling element; default the document). - Behavior (m3e-elements FloatingToolbarLayout, shared with m3e-react): expand/collapse springs (fast spatial, interruptible, velocity kept),
role="toolbar"with roving focus (one tab stop, arrows along the orientation, Home/End), hidden partsinert, and the scroll behaviors. - Events:
expanded-change(detail: { expanded }) when thecollapsescroll behavior expands or collapses it (the attribute follows).
| Attribute | Property | Type | Notes |
|---|---|---|---|
expanded
|
expanded
|
boolean
|
Reflected. |
orientation
|
orientation
|
string
|
|
color-style
|
colorStyle
|
string
|
|
label
|
label
|
string
|
|
fab-position
|
fabPosition
|
string
|
|
scroll-behavior
|
scrollBehavior
|
string
|
|
exit-direction
|
exitDirection
|
string
|
|
scroll-target
|
scrollTarget
|
string
|
| Event | Detail | Description |
|---|---|---|
expanded-change
|
ExpandedChangeDetail
|
Expanded or collapsed by the collapse scroll behavior.
|
CSS
floating-toolbar.css
@import '@kyralabs/m3e-css/floating-toolbar.css';
Floating toolbar (COMPONENT_SPEC §9; Compose HorizontalFloatingToolbar / VerticalFloatingToolbar and their with-FAB overloads). Static layout, both color styles, the expanded/collapsed states and the exit-on-scroll offset. Without JavaScript the with-FAB toolbar folds on the fast spatial curve and the leading/trailing items show or hide at once; <m3e-floating-toolbar> and m3e-react <FloatingToolbar> (m3e-elements FloatingToolbarLayout) add Compose's interruptible springs, the leading/trailing expand/shrink, roving focus and the scroll behaviors on the same classes.
<div class="m3e-floating-toolbar" role="toolbar" aria-label="Formatting">
<div class="m3e-floating-toolbar__bar">
<div class="m3e-floating-toolbar__leading">…icon buttons…</div>
…icon buttons…
<div class="m3e-floating-toolbar__trailing">…icon buttons…</div>
</div>
</div>
<div class="m3e-floating-toolbar m3e-floating-toolbar--with-fab m3e-floating-toolbar--vibrant"
role="toolbar" aria-label="Actions">
<div class="m3e-floating-toolbar__bar">…icon buttons…</div>
<div class="m3e-floating-toolbar__fab"><button class="m3e-fab" aria-label="Add">…</button></div>
</div>
Modifiers: --vertical (aria-orientation="vertical"), --vibrant (standard default), --collapsed, --with-fab, --fab-start (the FAB before the toolbar: start / top). Leading and trailing items show only while expanded; beside a FAB the whole toolbar folds into the FAB instead. Set inert on hidden parts yourself without JavaScript (the JS layers do it).
22 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-fab-baseline-icon-size
|
24px |
--m3e-comp-floating-toolbar-container-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-floating-toolbar-defaults-animation-duration
|
var(--m3e-sys-motion-spring-fast-spatial-duration) |
--m3e-comp-floating-toolbar-defaults-animation-easing
|
var(--m3e-sys-motion-spring-fast-spatial-easing) |
--m3e-comp-floating-toolbar-defaults-collapsed-shadow
|
var(--m3e-sys-elevation-level0-shadow) |
--m3e-comp-floating-toolbar-defaults-container-size
|
64px |
--m3e-comp-floating-toolbar-defaults-content-padding-bottom
|
8px |
--m3e-comp-floating-toolbar-defaults-content-padding-end
|
8px |
--m3e-comp-floating-toolbar-defaults-content-padding-start
|
8px |
--m3e-comp-floating-toolbar-defaults-content-padding-top
|
8px |
--m3e-comp-floating-toolbar-defaults-expanded-shadow
|
var(--m3e-sys-elevation-level0-shadow) |
--m3e-comp-floating-toolbar-defaults-expanded-shadow-with-fab
|
var(--m3e-sys-elevation-level1-shadow) |
--m3e-comp-floating-toolbar-defaults-fab-shadow
|
var(--m3e-sys-elevation-level2-shadow) |
--m3e-comp-floating-toolbar-defaults-fab-shadow-focused
|
var(--m3e-sys-elevation-level2-shadow) |
--m3e-comp-floating-toolbar-defaults-fab-shadow-hovered
|
var(--m3e-sys-elevation-level3-shadow) |
--m3e-comp-floating-toolbar-defaults-fab-shadow-pressed
|
var(--m3e-sys-elevation-level2-shadow) |
--m3e-comp-floating-toolbar-defaults-fab-shape
|
var(--m3e-sys-shape-corner-large) |
--m3e-comp-floating-toolbar-defaults-fab-size-max
|
80px |
--m3e-comp-floating-toolbar-defaults-fab-size-min
|
56px |
--m3e-comp-floating-toolbar-defaults-item-spacing
|
0px |
--m3e-comp-floating-toolbar-defaults-toolbar-to-fab-gap
|
8px |
--m3e-comp-minimum-interactive-component-size
|
48px |
Accessibility
role="toolbar" with aria-orientation and an aria-label; roving tabindex (one tab stop: the last focused item, else the first); ArrowLeft/Right (mirrored in RTL) or ArrowUp/Down when vertical; Home/End; no wrapping (APG toolbar; React Aria useToolbar keeps every item tabbable and has no Home/End, so both layers use m3e-elements RovingFocus, reusable by the docked toolbar §10).
hidden parts are not focusable (Compose's sample: focusProperties { canFocus = expanded }): collapsed leading/trailing parts, or the folded toolbar beside a FAB, are inert and focus inside moves to the FAB (or the first remaining item). An offscreen (exit-always) toolbar leaves the tab order (L190–196: canFocus = false while the offset is not 0) and comes back when focus moves into it; it does not hide while it holds focus (WCAG 2.4.11; Compose has no such rule).
Every interactive part has a target of at least 48 × 48 px, a visible focus ring for
keyboard focus only, and works under forced-colors and
prefers-reduced-motion.
Differences from Compose
no touch-exploration (TalkBack) detection, so the toolbar does not force itself expanded or drop its scroll behavior for screen readers, and Compose's expand/collapse custom accessibility actions have no web equivalent; the toolbar's Level0 → Level1 shadow fades the Level1 shadow by the progress instead of lerping the elevation; minimumInteractiveBalancedPadding (balances asymmetric 48dp alignment lines) is not needed for symmetric 48dp slots and not ported; no horizontal scrolling of an overflowing toolbar (Compose: horizontalScroll beside a FAB); the toolbar does not hide while it holds focus (WCAG 2.4.11); exit-on-scroll is measured against the offset parent or the viewport (Compose: the parent layout) and moves the root with translate, so position it with insets/margins.
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §9 and the m3e-css sources.
FloatingToolbar.kt (vendored), FloatingToolbarTokens.kt, FloatingToolbarSamples.kt, FloatingActionButton.kt (FloatingActionButtonDefaults.shape), ColorScheme.kt (contentColorFor).
FloatingToolbarTokens.kt@19991f2c plus the FloatingToolbarDefaults values Compose renders (components/floating-toolbar-defaults.css; SOURCES register #27).
The VibrantButton* tokens are not read by Compose at the pin (items inherit LocalContentColor).
floating-toolbar.css sources
(compose/material3 FloatingToolbar.kt @ 19991f2c; values in floating-toolbar.css and floating-toolbar-defaults.css, lines in PARITY.md):
- Toolbar (Horizontal/VerticalFloatingToolbarLayout, L1474-1560, L1697-1784): at least ContainerSize (64dp) across, ContentPadding 8dp, CornerFull, clipped; items centered with no spacing (Arrangement.Center: FloatingToolbarTokens.ContainerBetweenSpace is not read). Items are IconButtons whose minimumInteractiveComponentSize reserves a 48dp slot: ours lay out at their visual size (touch target via ::after), so the toolbar adds the difference as margin, as the top app bar does. Elevation Level0 expanded and collapsed.
- Leading / trailing (AnimatedVisibility, L1514-1557): expandHorizontally from Start / End and shrinkHorizontally towards End / Start on animationSpec() = fast spatial; vertical: expand from and shrink towards Bottom (leading) / Top (trailing). Hidden content is not composed.
- With FAB (L1562-1695, L1786-1919): a FloatingToolbarDefaults.FabSizeRange.endInclusive (80dp) tall row; width = toolbar max width + ToolbarToFabGap 8dp + FabSizeRange.start 56dp. With the expanded progress p (fast spatial; overshoots), the toolbar is (max width × p) wide, its content starting at its leading edge and its trailing edge fixed next to the FAB; the FAB (aligned to the outer end) is lerp(80dp, 56dp, p) square; the toolbar shadow lerps Level0 → Level1 with p clamped at 1 (here: the Level1 shadow at opacity p). FAB: FloatingActionButton with FloatingActionButtonDefaults.shape (baseline, 16dp) at every size, elevation Level2, hovered Level3; colors primaryContainer (standard) / tertiaryContainer (vibrant).
- Colors (defaultFloatingToolbar{Standard,Vibrant}Colors, L1108-1142): surfaceContainer / onSurface and primaryContainer / onPrimaryContainer (contentColorFor). Items inherit the content color (LocalContentColor), so standard icon buttons take it.
- Exit on scroll (ExitAlwaysFloatingToolbarScrollBehavior, L540-628): the JS layers write the offset (0 → minus the distance off the container) and the exit direction; the toolbar moves by it along that direction (logical, mirrored in RTL).