Skip to content

Navigation

Floating toolbar

A toolbar that floats over the content, horizontal or vertical, with an optional FAB; it can hide on scroll.

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.

React HTML
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.

React HTML
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.

React HTML
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.

React HTML
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 parts inert, and the scroll behaviors.
  • Events: expanded-change (detail: { expanded }) when the collapse scroll 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).