Components · Actions
FitToolbar
A toolbar whose items give way in named steps (label to icon, into More, a group to one button, a group to the next line, the primary action to +) as the region it sits in narrows, measured rather than guessed.
import { FitToolbar } from "@botiverse/raft-ui/wip";FitToolbar is a toolbar that keeps one row as its region narrows by letting its items give way, one step at a time, in an order they declare. It is the shared engine of When a row runs out of room: SelectionToolbar is built on it, and the list toolbars of Tasks, Computers, Members and search will use it (#proj-raft-ui:#225). It measures its content in the width of its own region, never the window's: a task list can be 320px or 512px wide on the same screen. So its parent must have a definite width (a column, a panel, a block that fills its row), not one sized by the toolbar: a parent that shrinks to fit the toolbar always has room for it.
When to use
Anatomy
FitToolbar: the toolbar, a Base UI Toolbar: one Tab stop, the arrow keys inside, skipping what is hidden.wraplets its groups move to lines of their own.FitToolbarGroup: a group of items in one row, a fixed gap apart.firstLinekeeps it on the first line;wrapOrdersays when it wraps;wrapLinenames the line it wraps onto;collapsedis the group as one button (a Filter button for its filters), shown fromcollapseOrderon.overflow="scroll"makes it scroll sideways instead (see Behavior).useFitItem(steps): makes an element an item; spread itspropson it.stepsgives each step the item can take its order:{ label: 30, more: 40 }.moreItemis what the item is in More's menu.taken(step)says which steps it has taken, for what it renders (a tooltip once its label is gone).FitToolbarButton: a toolbar button that can give way: anmdButton with an icon and a label, an icon square with its label as a tooltip fromlabelOrderon, a + square with its label as a tooltip fromplusOrderon, in More frommoreOrderon.collapsedIconis an icon shown only once the label has given way, in place oficon: a button that reads as text while it has room (Clear filters) and as an icon (×) once it hasn't. A button with neither an icon nor acollapsedIconrefuses itslabelstep, since nothing would be left to show.FitToolbarPicker: a picker's trigger as one toolbar item (a filter: Assignee, Status): a PickerTriggerButton with an icon, a label, what is chosen (value) and a chevron. Pass the popup's trigger astrigger(<DropdownMenuTrigger />,<PopoverTrigger />); the picker renders it as the toolbar button. With a value it fillsinfo-softby itself, as an applied filter does (Select › Filters), so the page styles nothing. A count (a number, or a string of digits) is set in Space Mono; any other value (In progress, Kirby) keeps the label's font, since Space Mono is for what machines write (Typography). FromlabelOrderon its label gives way and the icon, what is chosen and the chevron stay (# ⌄, 👤 1 ⌄): the chevron says it opens a list, as on every PickerTriggerButton. Its accessible name is the label, then what is chosen ("Assignee 1"), and the label shows as its tooltip.useFitMore(): makes an element the toolbar's More; spread itspropson it. Hide it until it carriesdata-fit-shown.FitToolbarMoreItems: the items in More, in their order: put it in More's menu.useInFitToolbar(): true inside a FitToolbar, for a control that would otherwise measure its own fit.
Behavior
- The steps, each written on the item as
data-fit-<step>for its styles:label: its label hides and its icon stays, the label kept as its accessible name and its tooltip.more: it moves into More (data-fit-more; hide it).collapse: a group shows its one button instead of its items.wrap: a group moves to a line of its own, only withwrap.plus: a primary action shows as + alone (FitToolbarButton'splusOrder), its icon and label gone, the label kept as its accessible name and its tooltip.
- The steps of every item go into one order for the whole toolbar: by the number each item gives them, the lower first; at the same number the last item first, and one item's steps in the order above (
label,more,collapse,wrap,plus). A step's name says nothing about when it comes: the numbers do. A list toolbar gives its numbers in this order: the filters' labels, then the groups'wrap, then the primary'splus(say 10, 20, 30). After each step the toolbar measures again, and stops at the first that fits. It starts from nothing taken on every measure, so the outcome depends on the width alone and doesn't flicker. It measures after every render, whenever its region changes size, whenever its content changes on its own (a label that becomes "Copied", a count, an item inside a child that rendered alone) and when a font arrives. - Items of the same kind in one group change form at the same time: the same
label(orplus) step at the same number, in oneFitToolbarGroup, is one step for all of its items, taken and given back together. A group already says its items are one kind, and a row of filters half icons and half labels reads as broken: at 780px Tasks showed three filters as icons beside Assignee with its label (sindresorhus; Jasper's call, 2026-11-10). Give an item another number to let it go on its own. - It wraps only between groups, from the end. Groups with the same
wrapLinewrap onto the same line, in their order, the toolbar's gap apart; a group without one takes a line of its own. Each line starts at the toolbar's start, the toolbar's gap below the one before, and its groups are never spread across it: a gap that grows with the width would be one more distance to read. The page decides which groups stay on the first line (firstLine), because it knows which controls people reach for first. - Once a group has wrapped, what its items gave before it wrapped is given back, the last first, wherever the toolbar still fits without it: filters that wrapped to the second line get their labels back, and so does a New task button there. The toolbar measures from nothing taken every time, so the result depends on the width alone: the same width gives the same toolbar whether it is narrowing or widening, and it never flickers between two.
- A group with
overflow="scroll"takes the rest of its line and scrolls sideways instead of giving way, its items whole, in a horizontal ScrollArea (a small scrollbar only while it scrolls, no tab stop of its own), the side that hides more fading (mask-fade-x, Edges fade). A vertical wheel scrolls it, and an item that takes focus, by Tab or the arrow keys, comes into view clear of the fade. It gives way itself only when it can't show one whole item. stdrc chose it for a phone's filters (2026-11-10): a row of filters that wraps pushes the list down, while one that scrolls keeps every filter one swipe away. It works as slock web's FilterStrip does, so the web can move to it. Whether narrow lists on a desktop scroll too is still open, so it is a group's choice, with no width built in. - An item the person applied or chose in a scrolling group (an active filter) is always in view: mark it
data-active, and the group scrolls it into view when it mounts and whenever it becomes active, with no animation. A filter that narrows the list must never sit hidden off the edge. Put applied filters first as well, so they are in view before any scrolling. - A SegmentedControl in the toolbar follows it: give it a
labelstep (spreaduseFitItem({ label })'s props on it) and it shows only its icons while that step is taken. Its owniconsOnlyBelowis off there (useInFitToolbar), because two measures of the same room would disagree: the toolbar knows what else gives way first. A control of text alone (Mine and All with their counts) refuses the step and keeps its labels: with no icons there would be nothing left to show. - A scrolling group scrolls only as its last state. While it can still wrap and hasn't, it counts as not fitting whenever its items overflow it, so the steps before its
wrap(the filters' labels) come first, then the wrap, and only a group on a line of its own scrolls. Before, at 780px the filters scrolled on the first line with their labels kept, the last ones hidden past the fade, where giving the labels fit them all (sindresorhus, 2026-11-10). A group that can't wrap (firstLine, or a toolbar withoutwrap) scrolls as before. - A scrolling group stays where the person scrolled it. Each measure starts from nothing taken, which can shrink its content for a moment (labels given before the wrap) and pull its scroll back; the toolbar keeps the scroll position across the measure. The active item is scrolled into view only when it newly becomes active (
data-activeadded, or an item added that carries it), and the group's first layouts keep it in view only until the person scrolls: a count changing, a measure or a resize after that leaves the group where they put it. Before, a count that changed on its own scrolled the filters back to the active one under the person's finger (risk 3 of sindresorhus' review, with Jasper, 2026-11-10). - A toolbar that floats over content leaves
wrapoff, because a second line would cover what it floats over: its last step is into More (SelectionToolbar). - The primary action becomes + only after the groups have wrapped: give its
plusa higher number than anywrap. It is the last step, because a + is the least clear form of the action people came for, and wrapping costs only a line. Tasks at 375px and 420px needs it (sindresorhus, #proj-raft-ui:#225): once the filters have wrapped to their own scrolling line, the first line (Mine and All with their counts, sort, list or board, Create task) fits only with Create task as +. Aplustaken before a wrap would not be given back after it, since it is on the first line. - Fits means nothing pokes out of the toolbar or out of any of its groups, and no line has broken by itself. Its items don't shrink (
shrink-0groups): a control is never squeezed below its own content. - It measures only what is laid out in the row: it looks through
display: contentsand leaves out what is hidden, out of flow (position: absoluteorfixed) or of no size. A popover, menu or tooltip opened from a toolbar item puts focus guards beside its trigger, fixed at the window's corner, and they are not part of the row: counted, opening a popup made the toolbar give way. Items on one line may differ in height (a primary Button beside 32px filters); a line has broken only where an item sits wholly below or above the first on it.
Composition
The Tasks toolbar: Mine and All (text alone, so they keep their labels), list or board and Create task stay on the first line; the filters (pickers with what is chosen, and Clear filters as × once it gives way) scroll, give their labels, then wrap to a line of their own (20) before Create task becomes + (30). Narrow the box to watch the steps.
Accessibility
- Give
FitToolbaranaria-label. The arrow keys skip what has moved into More, and More's menu holds it. - Focus follows the reading order, not the DOM's (WCAG 2.4.3, Focus Order): a wrapped group moves with CSS
order, so it can sit later on screen than where it is in the DOM, and neither the browser's Tab order nor Base UI's toolbar followsorder. The arrow keys go through the controls line by line as they show, in the DOM's order on each line, and Tab first enters the toolbar at the first control on screen, then wherever the person last was. A text field keeps the arrow keys while its caret can still move. - The toolbar is one Tab stop, a SegmentedControl in it too: the control registers as one toolbar item, its segments reachable by Tab only while it holds the toolbar's stop, and the toolbar's arrow keys move through its segments without choosing one (Space chooses; Base UI's radio ignores Enter on purpose).
- One Tab stop for the whole toolbar holds only while every control in it is a Base UI Toolbar part (
Toolbar.Button,Toolbar.Link,Toolbar.Input, or a trigger rendered through one, asFitToolbarButtonandFitToolbarPickerare) or registers as one item the way SegmentedControl does. A plain<button>dropped in stays a Tab stop of its own, and the arrow keys still reach it in reading order. - Up and Down choose nothing in a horizontal toolbar, on a segment too: a SegmentedControl's radio group would choose the next segment, while Left and Right only move between the toolbar's items.
- When the focused item moves into More (the region narrowed under it), focus goes to More, where the item now is, rather than falling to the page. Focus the person moved out of the toolbar stays where they put it.
- An item showing its icon alone keeps its label as its accessible name; show the label as a tooltip too.
Open
- It stays in wip until sindresorhus confirms the step names and the API for the list toolbars of #225.
- Where the right-hand group (list or board, Create task) sits once the filters wrap, at the end of the first line or after the filters, waits on stdrc's choice between A, B and C (#proj-raft-ui:#225). Today it stays at the first line's end.
- Moving an item out of the toolbar, to a page's menu or another region, is not a give-way step: where an item lives is the page's layout decision, so the page renders it there itself. FitToolbar has no step for it.
API
FitToolbar
| Prop | Type | Default |
|---|---|---|
| Groups may move to a line of their own (the `wrap` step) when the row runs out of room. Off for a toolbar that floats over content: a second line would cover it, so its last step is into More. | boolean | false |
| boolean | ||
| If `true`, using keyboard navigation will wrap focus to the other end of the toolbar once the end is reached. | boolean | true |
| The orientation of the toolbar. | BaseToolbar.Orientation | 'horizontal' |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
FitToolbarButton
| Prop | Type | Default |
|---|---|---|
| Its label: its accessible name and, once it has given way, its tooltip. | ReactNode | |
| An icon shown only once its label has given way (the `label` step), in place of `icon`: a button that reads as text while it has room ("Clear filters") and as an icon (×) once it hasn't. | ReactNode | |
| Its icon, which stays when its label gives way. A button with a `label` step needs one (or a `collapsedIcon`). | ReactNode | |
| When its label gives way (the `label` step). | number | |
| Busy: shows the spinner and blocks activation (click, Enter, Space and form submission) while keeping the button focusable. A busy button is `aria-disabled`, not natively `disabled`, so the focus a press left on it stays there and is still there when the wait ends. | boolean | |
| The spinner's accessible name while `loading`: say what is happening. | string | |
| What it is in More's menu. | ReactNode | |
| When it moves into More (the `more` step). | number | |
| When it shows as + alone, its icon and label gone (the `plus` step): for the primary action, after the groups have wrapped. | number | |
| "primary" | "accent" | "warning" | "danger" | "default" | "outline" | "information" | "muted" | "success" | "danger-secondary" | "danger-outline" | "ghost" | "on-frame" | "ghost" | |
| Whether the button should be focusable when disabled. | boolean | false |
| Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). | boolean | true |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
FitToolbarGroup
| Prop | Type | Default |
|---|---|---|
| The group as one button (a Filter button for its filters), shown once it collapses. | ReactNode | |
| When it shows `collapsed` instead of its items (the `collapse` step). | number | |
| Stays on the first line: it never wraps. The page decides which groups do. | boolean | false |
| `scroll`: the group takes the rest of its line and scrolls sideways instead of giving way, its items whole, the side that hides more fading. Mark an applied or chosen item `data-active`: it is kept in view. | "scroll" | |
| Groups with the same `wrapLine` wrap onto the same line, in their order, a fixed gap apart; a group without one takes a line of its own. | string | |
| When it moves to a line of its own (the `wrap` step), in a toolbar that allows it. | number | 0 |
| When `true` all toolbar items in the group are disabled. | boolean | false |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
FitToolbarPicker
| Prop | Type | Default |
|---|---|---|
| Its label ("Assignee"): part of its accessible name and, once it has given way, its tooltip. | ReactNode | |
| Its icon, which stays when its label gives way. | ReactNode | |
| When its label gives way (the `label` step). | number | |
| What it is in More's menu. | ReactNode | |
| When it moves into More (the `more` step). | number | |
| The picker's trigger, which opens its popup: `<PopoverTrigger />`, `<DropdownMenuTrigger />` or a Base UI `Menu.Trigger`. The picker renders it as a toolbar button, so the page nests nothing itself. | ReactElement<{ render?: unknown; }, string | JSXElementConstructor<any>> | |
| What is chosen: the value ("In progress", "Kirby") or a count (`1`). It stays in view when the label gives way, and follows the label in the accessible name. A count (a number, or a string of digits) is set in Space Mono, as machine-written text; any other value keeps the label's font. | (string | number | readonly string[]) & (ReactNode | null) | |
| Whether the button should be focusable when disabled. | boolean | false |
| Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). | boolean | true |
Also takes the attributes of its element.
Related
Changes
- 0.83.0 · AddedFitToolbar (from
@botiverse/raft-ui/wip): a Base UI Toolbar whose items give way in named steps as the region it sits in narrows:label(to an icon),more(into More),collapse(a group to one button) andwrap(a group to a line of its own, withwrap; groups with the samewrapLineshare one, and a wrapped group gets back what its items gave before it wrapped). Items declare their steps and order withuseFitItem, or are aFitToolbarButton; aFitToolbarGroup overflow="scroll"scrolls sideways instead, itsdata-activeitems kept in view; groups areFitToolbarGroup(firstLine,wrapOrder,wrapLine,collapsed,collapseOrder); More takesuseFitMoreand listsFitToolbarMoreItems. It measures its own region's width, never the window's, again when its content changes on its own, and writes each step asdata-fit-<step>. A SegmentedControl inside it follows itslabelstep; its owniconsOnlyBelowis off there.