DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ChatBubbleStreamingResponseCitationsToolCallPlanListParameterDiffModelSelectorElicitationArtifactPanelPromptInputKeyboardToolbar
AI agentsContributing
  1. Components
  2. KeyboardToolbar

KeyboardToolbar

A bar that rides the software keyboard — the formatting strip iOS Notes docks above the keys while you type. A floating capsule or a docked band around a normal Toolbar, with a dismiss button, focus that stays in the editor, and a sticky or hidden fallback when no keyboard is up.

$ pnpm add @tessinaui/ui

When to use

  • Actions that belong to the field being edited: formatting marks, insert tools (checklist, photo, link), undo and redo, an AI action. The bar sits where the thumbs already are.
  • Taps that must not dismiss the keyboard. A press on a bar item never takes focus, so the caret stays in the editor (keepFocus, on by default).
  • A short set of tools that scrolls sideways. Group them with separators and pin a leading or trailing action when one matters most (Pinned actions).
  • A single-field step that ends in one action. One full-width button riding the keyboard lets the step be submitted without hiding the keys first (Primary action).
  • A floating capsule or a docked band, chosen by how much of the editor you want to keep in view: floating leaves a gap above the keys, docked sits flush on them.

When not to use

  • For actions unrelated to what is being typed. Page-level navigation belongs in BottomNav; an always-visible set of tools on a desktop surface is a plain Toolbar.
  • For rarely used actions. Put them in a DropdownMenu opened from the bar (Menu) rather than adding another tool.
  • Inside a Drawer. The sheet already lifts with the keyboard; render the bar with portal={false} in its footer instead (Composition).
  • To rebuild the browser's own field bar. Some phone browsers draw their own previous, next and done strip above the keyboard for form fields; the bar sits above it, so do not duplicate it.
  • For several editors that need different tools on one screen. fallback="hidden" and the dismiss button react to any editable element on the page, so mount one bar per screen, next to the editor it serves.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

A desktop has no software keyboard, so every preview on this page renders the bar in place (portal={false}). To see it ride a real keyboard, open the device test page on a phone.

example.tsx
import { KeyboardToolbar, KeyboardToolbarDismiss, Toolbar, ToolbarButton } from "@tessinaui/ui";
example.tsx
{/* The iOS Notes bar — a floating capsule that scrolls, and a dismiss circle. */}
<textarea aria-label="Note" />

<KeyboardToolbar>
  <Toolbar aria-label="Formatting" variant="floating" rounded="full" className="flex-1">
    <ToolbarButton iconOnly leadingIcon={<ListTodo />} aria-label="Checklist" />
    <ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold"
      pressed={bold} onClick={() => setBold((v) => !v)} />
    <ToolbarButton iconOnly leadingIcon={<Camera />} aria-label="Attach photo" />
  </Toolbar>
  <KeyboardToolbarDismiss />
</KeyboardToolbar>

{/* A full-width band flush on the keyboard, shown only while editing. */}
<KeyboardToolbar variant="docked" fallback="hidden">
  <Toolbar aria-label="Insert" variant="ghost" className="flex-1">…</Toolbar>
  <KeyboardToolbarDismiss />
</KeyboardToolbar>

Examples

Default

The iOS 26 Notes bar: a floating capsule of tools that scrolls sideways, and a separate circle that hides the keyboard.

Docked

variant="docked" is a full-width band flush on the keyboard with a hairline top edge. Pair it with a ghost Toolbar so the band is the only surface.

Formatting

History first, then the marks. pressed paints the active mark and announces it, and pressing one keeps the caret in the note.

Menu

A style menu opened from the bar opens above it with side="top", because below the bar is the keyboard.

Pinned actions

An AI action leading and Send trailing, with the tools scrolling between them. Each pin is its own one-item Toolbar, so it stays put while the rail scrolls.

Stacked rows

Task details as chips above the formatting bar. Wrap the rows in a column and keep the dismiss outside it.

Labeled items

Text labels beside icons for actions a glyph alone does not name.

Fill

A short, fixed set spread across the whole width instead of scrolling. Use it only when the set fits at the narrowest width.

Primary action

One full-width button riding the keyboard, so a single-field step can be submitted without dismissing the keyboard first.

Disabled items

Unavailable actions stay in place, muted, so nothing shifts when they become available.

Fallback

What the bar does while no keyboard is up: sticky stays docked at the bottom (and is what a desktop shows); hidden appears only while something editable has focus. Focus either note to bring the right-hand bar in.

RTL

KeyboardToolbar has no dir prop, but a dir="rtl" attribute lands on the bar itself, portalled or not, so the tools start from the right and the dismiss capsule moves to the left. Give the inner Toolbar its own dir as well, because that is what flips its arrow keys.

Composition

The actions are a normal Toolbar. KeyboardToolbar only positions them: variants, sizes, the scroll rail, groups, pressed state and arrow-key traversal all come from Toolbar unchanged. The one exception: while a touch-screen user types, the bar's buttons leave the tab order (see Accessibility). Give the bar a name with aria-label.

Fill the width yourself. Add className="flex-1" to the Toolbar that should grow. The bar sets min-w-0 on every child Toolbar so a long rail scrolls instead of widening the page, but it leaves growth to you, so a pinned one-item Toolbar beside the rail keeps its own width.

Match the dismiss size to the bar. KeyboardToolbarDismiss sits beside the Toolbar, not inside it, so it stays pinned while the rail scrolls. It cannot read the Toolbar's size, so pass the same one: <KeyboardToolbarDismiss size="lg" /> next to <Toolbar size="lg">.

Inside a Drawer, let the drawer handle the keyboard. A bottom sheet already lifts with the keyboard, and a portaled bar inside it would lift twice. Render the bar with portal={false} in the sheet's footer instead.

API Reference

KeyboardToolbar

PropTypeDefaultDescription
variant"floating" | "docked""floating"floating is the iOS 26 capsule, inset from the screen edges, a gap above the keyboard. docked is a full-width band with a hairline top edge.
fallback"sticky" | "hidden""sticky"While no keyboard is up: sticky docks at the bottom of the screen, clear of the home indicator; hidden renders only while an editable element (or the bar itself) has focus.
portalbooleantrueRender into document.body as a fixed bar that follows the keyboard. false renders in place, in normal flow, with no keyboard tracking.
keepFocusbooleantrueKeep focus in the editor so the keyboard stays up. A press on a bar item never takes focus, and while a touch-screen user types, the bar's buttons render without a tab stop so VoiceOver cannot move focus off the editor. Menu, popover and disclosure triggers, links, inputs and your own controls stay focusable.

Renders data-slot="keyboard-toolbar", data-variant and data-state — keyboard (a soft keyboard is up), editing (an editable element has focus, no keyboard detected) or idle. The keyboard height is published as --keyboard-toolbar-inset.

KeyboardToolbarDismiss

PropTypeDefaultDescription
size"sm" | "md" | "lg""md"Match the size of the Toolbar beside it.
iconReactNodechevron downGlyph override.
forceMountbooleanfalseRender even when there is no keyboard to hide. By default it shows while a keyboard is up, and on touch devices while editing.
aria-labelstring"Hide keyboard"Accessible name — override for i18n.

Pressing it blurs the focused editor, which hides the keyboard. Call event.preventDefault() in your own onClick to keep focus.

Notes

  • How the keyboard is found. iOS Safari exposes no keyboard API (navigator.virtualKeyboard is Chromium-only), so the bar reads window.visualViewport against the tallest viewport it has seen at the current width: when an editable element has focus and the visual viewport has lost more than 60px of that height, a keyboard is up. Smaller changes are browser chrome — including iOS 26.0's 24px viewport residual after the keyboard closes. The 60px threshold and the pinch-zoom guard match Base UI's drawer, so a drawer and a keyboard toolbar agree. On a fine pointer the comparison is against the live window instead, so resizing a desktop window while typing never reads as a keyboard.
  • One trigger per page. fallback="hidden" and the dismiss button react to any editable element on the page having focus, not to one particular editor. Mount one bar per screen, next to the editor it serves.
  • No re-render per frame. The inset changes on every scroll frame while the keyboard is up. It is written straight to a CSS variable; React re-renders only when the state changes.
  • The keyboard's own animation. iOS reports the keyboard's final size after it starts moving, so the bar cannot track the spring exactly — it eases over 200ms instead, and follows a pan with no easing. The first time the keyboard opens the bar can arrive late; after that it uses the last measured height the moment a field gets focus.
  • When the layout viewport shrinks too. iOS 26 Safari, and Android Chrome under interactive-widget=resizes-content, shrink the layout viewport with the keyboard. The state is still keyboard, but the inset is 0px: the layout viewport's bottom edge already sits on the keyboard, so the bar docks there with nothing left to compensate for. Pre-26 iOS and Android's default resizes-visual shrink only the visual viewport; the difference is the inset the bar translates by.
  • Form fields on iPhone. Safari draws its own ‹ › ✓ bar above the keyboard for form fields. The visual viewport already excludes it, so the bar sits above it rather than on top of it. Do not rebuild that bar.
  • Safe area. With the keyboard down, the bar clears the home indicator with env(safe-area-inset-bottom); with it up, that padding drops, because the keyboard covers the strip. The page must set viewport-fit=cover for Safari to report the insets at all.
  • Reduced motion removes the transition; the bar jumps to its new position.

Accessibility

  • The bar itself has no role. The Toolbar inside it is role="toolbar" with one tab stop and arrow-key traversal on a desktop. Name it with aria-label.
  • keepFocus does two things. A press on a bar item never moves focus. And while a touch-screen user types, ToolbarButton, the toolbar's overflow chevron and the dismiss render as role="button" elements with no tab stop. VoiceOver on iOS gives any focusable control it lands on real focus, which blurs the editor and closes the keyboard; an element VoiceOver can press but not focus leaves the keyboard open.
  • What still takes focus: menu, popover and disclosure triggers (an item with aria-haspopup or aria-expanded), links, ToolbarInput, and your own controls such as a Chip or a Button. VoiceOver moving onto one of them still closes the keyboard.
  • The cost of that: on an iPad with a hardware keyboard, Tab from the editor skips the bar's buttons, because they have no tab stop while the editor has focus. Shift+Tab from after the bar still reaches them. keepFocus={false} keeps real buttons everywhere.
  • The dismiss button is named "Hide keyboard" and is absent where there is no keyboard to hide, rather than present and inert.
  • VoiceOver, tested 2026-09-15. On macOS Safari the bar reads as "Formatting toolbar", a pressed mark announces "selected", and the dismiss is named "Hide keyboard". On an iPhone (iOS 26.6.2), moving VoiceOver onto a bar button closed the keyboard, because iOS gave the button focus. Two fixes did not work on the device: returning focus to the editor after a tool was pressed (iOS does not reopen the keyboard), and returning it the moment VoiceOver lands on a button (VoiceOver follows focus back to the editor). Items that VoiceOver can press but not focus kept the keyboard open in every test, which is what the bar now renders. The shipped form was then run on the same iPhone: VoiceOver moved through the whole bar with the keyboard up, Bold and Italic toggled from a double-tap with the keyboard still up, and "Hide keyboard" closed it. NVDA and JAWS: untested (they do not run on iOS).
PromptInputAI Chat

On this page

When to useWhen not to useInstallationUsageExamplesDefaultDockedFormattingMenuPinned actionsStacked rowsLabeled itemsFillPrimary actionDisabled itemsFallbackRTLCompositionAPI ReferenceKeyboardToolbarKeyboardToolbarDismissNotesAccessibility