widgets
Every component is a free function taking a Ui& and an options struct. See Writing a component to add your own.
One component, one header. button is gbui/widgets/button.hpp, slider is gbui/widgets/slider.hpp, and so on, so a translation unit includes what it uses. Four umbrellas gather the groups for callers who want all of it:
| Umbrella | What it pulls in |
|---|---|
gbui/widgets/elements.hpp | text, hyperlink, label, icon, image, badge, avatar, chip, kbd, spinner, skeleton, progressBar, divider, spacer, breadcrumbs, pagination — and the input set: button, checkbox, radio, toggle, select, segmented, slider, textInput, textarea, and the field that wraps one |
gbui/widgets/containers.hpp | box, panel, listRow, toolbar, accordion, scrollArea, scrollbar, table, tabs, tabPanels, virtualList, marquee, splitPane, treeView, carousel, gallery, compare |
gbui/widgets/overlays.hpp | tooltip, popover, menu, contextMenu, menuItem, modal, drawer, banner, toast |
gbui/widgets/components.hpp | the composed editors — colorPicker, datePicker, timePicker, dateTimePicker, richEditor, each with its field form — and the charts |
Which group does a thing belong to? Four questions, asked in this order. The order is the taxonomy; asking them in any other one produces two axes and no answer for the things that sit on both.
- Is its job the content inside it? → a container. This is why
panelandtoolbarare containers rather than the composed components they plainly are: what they are for is arranging their children. - Does it leave the flow and float? → an overlay.
- Is it a leaf with a counterpart in HTML? → an element. It draws itself and decides nothing beyond the theme it takes its colours from. This is why
selectis an element: it opens a popover, but so does a native<select>, and the question is asked of the control, not of its menu. - Otherwise it is composed and has opinions → a component.
datePickerdecided what a month looks like;colorPickerdecided where the hue rail goes.
All four are the same kind of function to the compiler. The groups are for readers, and for the umbrella a translation unit includes.
controls.hpp
The old fifth umbrella still exists and still compiles — it now includes elements.hpp and components.hpp. Prefer the one you mean in new code: it is shorter, and it says which half you are reaching for.
Interactive components take an Interaction and an id, report what the user did, and hold no state: the value is yours.
if (checkbox(ui, input, "settings.tags", value, {.label = "Show tags"}))
value = !value;The component never writes to your model. It says what happened and leaves the decision with you, which is what makes undo, validation and "are you sure?" possible without the toolkit knowing about any of them.
Text
NodeId text(Ui&, std::string_view value, const TextOptions& = {});
NodeId sectionHeading(Ui&, std::string_view value);
NodeId strong(Ui&, std::string_view, TextOptions = {}); // importance
NodeId emphasis(Ui&, std::string_view, TextOptions = {}); // stressstruct TextOptions {
Token color = Token::Text;
FontWeight weight = FontWeight::Regular;
FontSlant slant = FontSlant::Normal;
FontRole role = FontRole::Ui;
float size = kAuto;
TextAlign align = TextAlign::Start;
float grow = 0.0f; // take the rest of the row
TextOverflow overflow = TextOverflow::Ellipsis;
int maxLines = 0; // wrapping only; 0 is unlimited
float lineHeight = 0.0f;
bool underline = false, strikeThrough = false;
Gradient gradient{}; // across the run
};sectionHeading is the small, muted, uppercase heading — "UNSTAGED (3)". The two shorthands carry the meaning HTML gives them rather than the appearance: strong happens to be drawn semibold and emphasis italic today, and a call site that says what it means is one a restyle does not have to visit.
Mixed runs
richText(ui, {{"on branch "}, {"main", Token::Accent, {}, FontWeight::SemiBold},
{", 3 files changed"}}, {.wrap = true});A node holds one TextStyle and therefore one colour, so a line whose runs differ is a row of spans rather than a string with markup in it. Keeping them as data is what stops this becoming a parser.
wrap lets the line break between spans — not inside one, which would need an inline formatting context the engine does not have. Splitting a sentence into more spans gives the layout more places to break. A paragraph that must break mid-sentence wants text with TextOverflow::Wrap instead.
Button
NodeId button(Ui&, std::string_view label, const ButtonOptions& = {});
NodeId button(Ui&, const Interaction&, std::string_view label, const ButtonOptions& = {});
struct ButtonOptions {
ButtonVariant variant = ButtonVariant::Secondary; // Primary Secondary Ghost Danger
std::optional<Icon> leading; // drawn before the label
bool disabled = false;
bool block = false; // fill the row
float height = 0.0f; // 0 = the design's control height
std::string_view id; // tag
std::optional<bool> ripple; // unset asks the Design
};Disabled is drawn, not merely flagged: the button renders at 45% opacity and stops taking focus.
The Interaction overload is what a ripple needs — the ink grows from the point the press landed, which the frame knows and the builder does not. Unset, ripple asks the active Design: Material throws ink, the others change the surface.
Image
#include "gbui/widgets/image.hpp"
NodeId image(Ui&, const Bitmap&, const ImageOptions& = {});
struct Bitmap { const std::uint8_t* pixels; int width, height, stride; };
enum class ImageFit { Fill, Contain, Cover, None };HTML's <img>, with the parts of it that are a toolkit's business: a box, a fit, a radius, an opacity and an alt. ImageFit is CSS's object-fit and is named after it — Contain letterboxes, Cover crops, and both are cut by the box, which is why the picture is drawn as a child of it rather than on it.
The pixels are borrowed for the frame. They are read when the frame is painted, not when image is called, and nothing copies them — the same contract a label's string_view has, and the same one it breaks the same way. Put them in a member, a cache or a static; a buffer that dies with the enclosing scope is a picture of whatever the stack holds by the time anyone looks.
The picture is sampled bilinearly, because a logo scaled into a table row is almost never at its own size and nearest-neighbour turns its edges into a staircase. Corners are cut by the same coverage the fills use, so an image in a rounded box and the box agree about the curve. SvgPainter carries it too, as a base64 PNG written on the way out — an exported document that quietly dropped the pictures would be the one thing that painter exists not to be.
Getting the pixels
#include "gbui/platform/image.hpp"
class Image {
static Image fromFile(const std::string& path);
static Image fromMemory(const std::uint8_t*, std::size_t);
Bitmap bitmap() const; // valid while the Image is
const std::string& error() const;
};PNG, JPEG, BMP, GIF, TGA and PSD, through the vendored stb_image.h. It lives in platform/ for the reason everything else does: that is where the machine is, and core, style, scene, layout, paint and widgets stay free of third-party code. Image owns and Bitmap borrows, which is the whole reason there are two — decode once into something you keep, hand a view to the widget each frame.
A failure carries a reason. "The picture did not appear" has a dozen causes and the decoder is the only thing that knows which one it was.
Icon and badge
NodeId icon(Ui&, Icon which, const IconOptions& = {}); // color, size, stroke
NodeId badge(Ui&, std::string_view value, const BadgeOptions& = {});A badge is a pill, and it never shrinks: a branch name that elides to nothing is worse than a row that overflows. See Icons for the set and how to extend it.
The small ones
NodeId spinner(Ui&, const SpinnerOptions& = {});
NodeId avatar(Ui&, std::string_view name, const AvatarOptions& = {});
std::string initialsFor(std::string_view name);
ChipResult chip(Ui&, const Interaction&, id, std::string_view label, const ChipOptions& = {});
NodeId kbd(Ui&, std::string_view keys, const KbdOptions& = {});
NodeId skeleton(Ui&, const SkeletonOptions& = {});spinner and progressBar are both here on purpose. A bar is a horizontal rule that wants a row of its own; a spinner is a glyph that goes inside things — in a button that is submitting, at the end of a row that is loading. Use a bar when you know how far along you are: a spinner says only "still working", and a reader watching one cannot tell whether to wait or leave.
Both take a phase rather than keeping a clock, like everything else here that moves. Feed it frame.time.
avatar is its fallback. Drawing a bitmap in a circle is four lines; what to draw when there is no bitmap is the part applications get wrong, and a grey circle is indistinguishable from a broken one. With no picture it draws the initials on a colour derived from the name by hash — stable, so the same person is the same colour on every screen and in every session with nobody storing one. square is the signal for a thing rather than a person: an organisation, a repository, a bot.
chip is not badge. A badge is output — a count, a status, something the reader cannot act on. A chip is input: a filter that is on, a tag that can be taken off. That is why they are two components and not one with a flag; giving badge a press and a focus ring would put every status pill on every screen on the Tab route. A chip announces itself as a toggle button with pressed, its × is named after it — "Remove feat/nord", not "Remove" six times — and Delete or Backspace removes it from the keyboard.
kbd splits the string. kbd(ui, "Ctrl+Shift+P") is three caps with the pluses between them, because a caller should be able to write a shortcut the way they would say it. The caps are hidden from the accessibility tree and the group carries the whole shortcut as its name, so a reader hears it once instead of three loose letters.
skeleton is a lie with a short shelf life. It exists to stop the page jumping when the data lands, not for the shimmer. Past about a second it is worse than a spinner, because the reader is looking at a layout that is not there. Name only the first of a set: six placeholders that each announce themselves are six announcements of nothing, so every unnamed one is hidden.
Banner
BannerResult banner(Ui&, const Interaction&, id, std::string_view title,
const BannerOptions& = {});The other half of toast, and applications reach for the wrong one constantly. A toast is transient and global: a corner, a sentence about what just happened, gone. A banner is persistent and local: in the layout, above the thing it is about, still there because the condition is still true.
"Merge in progress — 3 conflicts remain" is a banner; a reader who looked away would have lost it as a toast, and the merge is still going on. "Pushed 3 commits" is a toast; there is nothing left to do about it and it would sit there forever as a banner. The rule: if dismissing it would lose information the reader still needs, it is a banner.
Danger and Warning take ARIA's alert, which interrupts a screen reader mid-sentence; Info and Success take status, which waits for a pause. That is the whole reason the kind is not just a colour.
Why the four kinds differ by shape
This palette has 24 tokens and no amber — the token list is a contract shared with the theme registry, not somewhere to invent a colour — so Warning borrows Modified, which is a blue, and sits next to the accent's blue. Colour was never a safe signal on its own anyway. A triangle is a warning everywhere, a circled i is a note, a tick is done and a circled ! is a failure, and all four read at a glance in one colour.
Containers
Ui::Scope box(Ui&, const BoxOptions& = {});
Ui::Scope panel(Ui&, const PanelOptions& = {});
Ui::Scope toolbar(Ui&, const ToolbarOptions& = {});
Ui::Scope listRow(Ui&, const ListRowOptions& = {});
NodeId spacer(Ui&, float grow = 1.0f);
NodeId divider(Ui&, Direction containerDirection);box is the general container, the way <div> is one — ui.scope(Style{…}) already builds anything the layout engine can express, so this is the ergonomics. The presets are functions returning options, not a second API:
auto card = box(ui, BoxStyle::card());
auto sidebar = box(ui, BoxStyle::sidebar());
auto bar = box(ui, BoxStyle::navbar({.height = 44.0f}));
auto body = box(ui, BoxStyle::section());
auto middle = box(ui, BoxStyle::centre());BoxOptions carries the layout, size, appearance, cursor, id and focusable fields a container repeats; panel stays as the older, narrower form of card.
struct ListRowOptions {
bool selected = false;
bool hovered = false;
float height = 28.0f;
Edges padding = Edges::symmetric(0.0f, 12.0f);
float gap = 6.0f;
std::string_view id;
};selected washes the row in the accent at 18%; hovered uses surfaceHover. Both are passed in — components hold no state.
divider takes the direction of the container it sits in, because a rule spans the cross axis and is one pixel on the main one.
ToolbarOptions::bottomBorder draws nothing
Border is all four edges, so a toolbar cannot ask for a rule on one of them. Add divider(ui, Direction::Column) after the toolbar until the primitive grows per-edge widths.
Scrolling and virtualised lists
#include "gbui/widgets/scroll.hpp", virtualList.hpp
Ui::Scope scrollArea(Ui&, const Interaction&, id, ScrollState&, const ScrollOptions& = {});The content is laid out at its natural size, clipped to the viewport, and moved by state.offset. The wheel scrolls it while it is the innermost scrollable node under the pointer; Page Up, Page Down, Home and End do too when focus is on it or inside it; the bar can be dragged. ScrollState is the application's:
struct ScrollState {
float offset; // yours to set — restore it and the view opens where it was
float contentSize; // written by the component from last frame's geometry
float viewportSize;
float maxOffset() const;
bool scrollable() const;
float progress() const; // 0 at the top, 1 at the bottom
};ScrollOptions carries direction, an explicit axis (None | Vertical | Horizontal), the wheel step, the bar's width and auto-hiding, focusable, and minimums and maximums on the viewport — a maximum is what turns "as tall as its content" into "as tall as its content, then scroll", which is the rule a dropdown needs. None still clips: a box that must not grow past a size but must not scroll either is a real thing. Both axes at once is not built, and is named rather than half-done.
A bar that gets out of the way
scrollArea(ui, input, "log", state, {
.scrollbarVisibility = ScrollbarVisibility::WhileUsed,
.scrollbarRestOpacity = 0.2f,
});SimpleBar's behaviour, and off by default. The bar is full while the view is being used — the pointer inside it, the bar hovered or held, the keyboard on it, or a wheel notch aimed at it — and fades back to scrollbarRestOpacity after scrollbarFadeDelay. The delay is on the way out only; coming back is immediate, or the bar arrives after the reader has started looking for it.
Why it is off by default. A bar is a position indicator as much as a control: it says where you are in the content and how much is left, and a view that hides it has stopped saying either. That is why scrollbarRestOpacity is worth a number like 0.2 rather than 0 — it keeps the indicator and still gets out of the content's way. At 0 the bar is not drawn at all rather than drawn transparent, because a track nobody can see that pages the view when clicked is worse than no track.
autoHideScrollbar is a different question and stays on: that one is about there being nothing to scroll, and this one is about the bar while there is.
Everything inside a scroll view costs a node, so 50 000 rows really do build 50 000 nodes. When the rows are uniform, don't:
VirtualSlice shown = virtualList(ui, input, "history", state,
{.count = commits.size(), .rowHeight = 28.0f},
[&](Ui& ui, std::size_t index) {
auto row = listRow(ui, {.id = rowId(index)});
text(ui, commits[index].subject, {.grow = 1.0f});
});row is called once per visible index, in order, inside a box of exactly rowHeight — the list enforces the height rather than trusting the caller, because a row that laid itself out taller would slide the ones after it out of step with the scrollbar.
The rows that are not visible become two spacers, one standing in for everything above the slice and one for everything below. The content is therefore the full height it would have been, so the scrollbar, maxOffset and Page Down all keep working on the real list while the arena holds the forty rows a person can see.
struct VirtualSlice { std::size_t first, count, total; float pitch; };Returned rather than kept, so a caller can say "showing 41–78 of 50 000" without counting anything. overscan (2 by default) builds that many rows above and below the viewport, which is what keeps a fast drag from showing an edge — the viewport is last frame's, like everything else that needs geometry before layout has run.
struct RowMetrics { float height, gap, top; float pitch() const; };
void revealRow(ScrollState&, const RowMetrics&, std::size_t index);Scrolls the least distance that brings a row fully into view, and does nothing when it already is: a row one line below the fold moves one line rather than jumping the list under the reader. This is what arrow-key navigation over a list needs, virtualised or not — a select's open list uses the same call. VirtualListOptions::rows() hands over the metrics of a virtualised one.
void scrollbar(Ui&, const Interaction&, id, const ScrollState&, Rect box,
ScrollAxis = Vertical, float width = 10.0f, bool autoHide = true);A bar for a view that is not where the bar belongs. Normally scrollArea draws its own and this is not needed; the table is the case that forced it out. box is where the bar should go, in the current container's coordinates, and the ids are the view's own — so the press, the drag and the paging are still handled by scrollArea. It draws; it does not behave. Turn the view's own bar off with ScrollOptions::scrollbar when you use it, or there will be two.
Split pane
Two panes and a divider the reader can drag — the shape every IDE-shaped application is built from, nested.
const SplitPaneResult result = splitPane(ui, input, "workspace", model.split,
[&](Ui& ui) { sidebar(ui); },
[&](Ui& ui) { editor(ui); },
{.name = "Sidebar width"});
if (result.changed) model.split = result.position;The share is a percentage basis, not a grow ratio, and that is worth knowing before reaching for grow yourself. This engine computes its free space from the hypothetical sizes — already clamped to each item's minimum — so two panes with a 120-pixel floor take their 240 first and split only what is left: asking for a quarter of 600 gets 208 rather than 148. A basis of p% plus shrink is exact, because the overflow the divider causes is taken back in proportion to the bases.
The minimums are the layout's rather than the drag's, which is what makes them hold when the window shrinks under a split nobody touched — the case a clamp in a drag handler silently misses.
Accessibility. The divider is ARIA's window splitter: a Separator that takes the keyboard and carries a value, answering the arrows at 2% and Shift at 10%, plus Home and End. A split only draggable with a pointer is a layout most people cannot change. Name it after what it resizes — "Sidebar width" says more than "Resize" — and name the panes, since that is what tells a reader which of the two the arrows are about to change.
Tree view
An expandable hierarchy: a branch sidebar, a file tree, an outline. The inventory calls it the single biggest gap for a git client, and the reason is that the three things it needs are each easy and never all three — an expansion model, keyboard walking that matches every file browser a reader has used, and virtualisation, because a repository's file tree is not a hundred rows.
The data is a flat vector in pre-order with a depth on each row, not a recursive structure and not a "give me the children of X" callback. Flat is what makes virtualisation possible at all — a slice of a tree is only a slice if the tree is already a sequence — and it is what the caller usually has.
const std::vector<TreeItem> files = {
{.id = "src", .label = "src", .hasChildren = true},
{.id = "src/ui.cpp", .label = "ui.cpp", .depth = 1},
};
treeView(ui, input, "files", files, model.tree, {.name = "Files"});hasChildren is deliberately not "has rows after it at a greater depth": a lazy tree knows a directory has contents before it has read them, and a twisty that appears once the contents arrive is a twisty nobody presses.
The keys are the point. Right opens a closed node and steps into an open one; Left closes an open one and steps out of a closed one. That pair is the whole of why a tree feels like a tree — making Right always step turns it into an indented list. Up and Down walk what is on screen, Home and End reach its ends, and Return or Space chooses.
Clicking the twisty opens without choosing and clicking the row chooses: "show me what is in here" and "I want this one" are two gestures, and a file browser that conflates them selects a directory every time somebody looks inside it. The selection and the keyboard's row are likewise two things, the same separation select makes between its highlight and its value.
Accessibility. One Tab stop for the whole hierarchy with activeDescendant carrying the row — the only arrangement that keeps Tab a way out of a nine-hundred-row tree. Each row is a TreeItem with level and a position counted among its siblings, because "item 2 of 5" in a hierarchy means whose five, and "row 340 of 900" is the size of the repository rather than of the directory the reader is in. expanded is set only where there is something to expand.
Carousel
A strip of slides, one screenful at a time. The content is a callback and the position is the component's business, which is what makes it a container: CarouselState::first is the index of the slide at the leading edge, and it moves by slides rather than by pages even when several are showing — "next" is the thing after the one you are looking at, not a jump that takes away everything you were reading.
slidesPerPage takes a fraction. 2.5 shows two and half of the next, which is not decoration: a strip cut cleanly at the edge looks like it ends there, and half a slide is the only thing that says otherwise without spending a control on saying it.
An autoplaying carousel always draws a pause button, and there is no option to remove it. Anything that moves on its own for more than five seconds needs a way to stop it — WCAG's "pause, stop, hide", one of the few rules that is a rule rather than a judgement — so an option to remove the button would be a switch labelled "make this inaccessible". Hovering the slides pauses it too, and so does the keyboard being inside them; reaching for a control does not, because otherwise pressing Play would leave focus on Play and refuse to move.
Accessibility. The strip is one keyboard stop and the arrows move inside it, the roving pattern tabs already uses. Slides that are off screen are hidden from the accessibility tree rather than left in it — what PrimeVue does, and right: eight slides all present at once turns a control into a list a reader has to find their way out of. Each carries "3 of 8". The dots are a TabList of Tabs with activeDescendant on the current one, which is one of the two patterns ARIA blesses for a carousel and the one that fits.
Gallery
One picture at a time out of a set, with the rest along the bottom: a main image, arrows over it, a caption, and a thumbnail strip that doubles as the way to jump straight to one. The strip keeps the current thumbnail in view, so forty pictures are walkable from the keyboard.
What it is not. PrimeVue's Gallery also zooms, rotates, flips, downloads and goes fullscreen. Each of those is missing because the thing under it does not exist yet, and a rotate button that does not rotate is worse than no button: zoom and rotate need a transform on a node, which the painter has not got; download needs a native file dialog, and nothing here touches the filesystem; fullscreen is a second window. All four are named in the header against the work that unblocks them.
Accessibility. Every picture has a name — its alt, or its caption, or "Image 3 of 9" — because an unnamed picture in a set of nine is "image, image, image". The stage is the image rather than a group wrapping one, since there is exactly one thing there. One keyboard stop for the whole gallery, with the thumbnails a TabList following it.
Compare
Two things in the same rectangle, with a handle that says how much of each — a before and an after. Both are drawn at the full size of the box and one is revealed over the other, which is what makes the comparison work: the reader is looking at the same pixels in the same place rather than remembering one while they look at the other.
const CompareResult result = compare(ui, input, "shot", model.seam,
[&](Ui& ui) { image(ui, original); },
[&](Ui& ui) { image(ui, retouched); },
{.beforeLabel = "Original", .afterLabel = "Retouched"});
if (result.changed) model.seam = result.position;The seam is clipped by a percentage, not by a measured width, and that is the part worth knowing. A clip sized from last frame's geometry would be a frame late and would jump on every resize; a percentage resolves during layout, so it is right on the first frame. The content inside that clip is 100 / position percent of it, which comes back out to the full width of the box — a layout identity rather than an arithmetic one. The handle is placed the same way, by two flexible spacers, which also keeps it wholly inside the box at either end.
slideOnHover is off, because a comparison is something a reader sets and then looks at, and a seam that moves whenever the pointer crosses the picture cannot be left anywhere.
Accessibility. It is a slider and genuinely one: a value from 0 to 1, the arrow keys at 2% and Page at 10% — finer than the colour picker's 5%, because a seam is aimed at an edge and a colour at a region — plus Home and End. The value is announced in words, "60% Retouched", since "60 percent" alone says neither how much of what nor revealing what. Both sides are named and both stay in the tree whatever the handle is doing, because a reader who cannot see them is not comparing them by eye and hiding one would leave them with half of it.
Marquee
struct MarqueeState { float offset = 0.0f; };
void marquee(Ui&, const Interaction&, id, MarqueeState&, float delta,
const std::function<void(Ui&)>& content, const MarqueeOptions& = {});A strip whose contents slide past and come round again — a ticker along the top of a trading screen, a status band, a row of logos. The content is drawn twice, side by side: one copy leaves a hole behind it as it travels and the second, exactly a content's width back, fills it, so the seam never arrives at a moment anyone could see. It is built twice per frame, so it should be a row of labels rather than a table.
A position, not a clock, and that is the difference between a strip that slides and one that twitches. Derived from a clock the position would be fmod(seconds * speed, contentWidth) — so the instant the content changes width, which for a ticker is every time a number gains a digit or a sign, the modulus lands somewhere else and the whole strip jumps sideways. Advanced by the frame's delta instead, a change in width moves only where the next wrap will be.
delta of zero stops it, which is the one interaction a ticker has: held still while the pointer is over it, a reader can read a name instead of chasing it. Stopping is the caller's to decide because only the caller knows what should stop it — demos/src/markets.cpp spends one line on it.
A tape's cards should carry snapshots. Each card on that screen says what a name traded at at a moment and is never rewritten; new ones are printed and old ones fall off the end. A card whose number kept changing under the reader would be a live cell that happens to be sliding, which is a different thing and a worse one.
Both passes are out of the flow, which is how they slide and also why the strip takes its size from grow and its height by stretching: there is no content in the flow to measure either from, and an unsized one collapses to an empty band.
Table
#include "gbui/widgets/table.hpp"
TableResult table(Ui&, const Interaction&, id, const std::vector<Column>&,
std::size_t rowCount, TableState&,
const std::function<void(Ui&, std::size_t row, std::size_t column)>& cell,
const TableOptions& = {});What makes a table a table is the part a list of rows cannot do: the widths are resolved once for the whole table and handed to the rows, so every row's third cell starts at the same x. cell is called for each visible cell and builds whatever belongs there, into a box that is already the right width.
struct Column {
std::string_view title;
ColumnSize sizing = ColumnSize::Fraction; // Fixed | Fraction | FitContent
float width = 1.0f; // pixels, or the share
std::string_view fitSample; // the widest value expected, for FitContent
float minWidth = 48.0f, maxWidth = kAuto;
TextAlign align = TextAlign::Start;
bool sortable = false;
bool resizable = true;
};FitContent measures the header's title and fitSample, not the cells, and that is a real limit rather than an oversight: a cell is a callback building arbitrary UI, so asking it how wide it would like to be means building the whole table twice. For a commit hash, a date or a count — the columns that actually want fitting — a sample is exact.
sortable is off by default and deliberately: this widget owns the geometry, not the data. It reports that the reader asked for a different order and the application reorders its rows. A column that advertises sorting it does not implement is worse than one that never offered.
struct TableState {
ScrollState body, columns; // vertical, and the shared horizontal one
std::vector<float> widths; // what the reader dragged; kAuto keeps the rule
int sortColumn = -1; bool ascending = true;
std::size_t selected = npos;
};Dragged widths live in the state rather than in Column so the layout rules a developer wrote and the widths a reader chose stay separate — shipping a new column order should not throw away either. The header and the rows share one horizontal scroll so they cannot drift apart.
TableOptions carries row and header heights, stickyHeader, zebra, virtualise (on, and reusing virtualList), cell padding and the three kinds of rule: gridLines under the header, rowLines and columnLines. Both row and column lines are off by default — on a table read top to bottom the rows are already separated by their alignment, and a line under each is a lot of ink for no information.
TableResult reports sortChanged, selectionChanged, the shown slice and the resolved columnWidths.
Both scrollbars belong to the table, not to what scrolls. The rows scroll up and down inside a box that scrolls side to side, so the row view's own right-hand edge is out at the end of the widest column: a bar drawn against it is one the reader has to scroll sideways to find, and once the columns are wide enough, one they cannot reach at all. The rows' bar is turned off and drawn against the table's own box instead — which is where a browser puts it — through scrollbar() below.
A header does not light up on hover. A row does, because the reader is picking one out of many; a column title is not picked out of anything, and the two things it does need to say — that it can be clicked, and which way it would sort — are already carried by the cursor and by the arrow every sortable column wears permanently.
Tabs
std::optional<std::size_t> tabs(Ui&, const Interaction&, id,
const std::vector<TabItem>&, std::size_t selected,
const TabsOptions& = {});
void tabPanels(Ui&, std::size_t selected,
const std::vector<std::function<void(Ui&)>>& panels,
const TabPanelsOptions& = {});The strip is one focusable stop — the ARIA roving-tabindex pattern — so Tab moves past the whole strip rather than through every tab in it, and the arrow keys move between them once it has the keyboard. Home and End jump to the ends and disabled tabs are skipped. The indicator slides to the new tab when an animator is present and simply appears when there is not.
TabsOrientation::Vertical is a sidebar: same component, same keys, the indicator down the leading edge. TabItem::group draws a heading above the first tab of a run — vertical strips only, and drawn between tabs rather than being one, so the keyboard walks straight past it.
tabPanels is separate because the two are rarely siblings: a vertical strip sits beside its panels and a horizontal one above them. An unselected panel is built inside a box of no height that clips, so it takes part in nothing the reader can see while still being there — lazy turns that off and skips it entirely, which is cheaper and means frameOf finds nothing inside it.
Not built: an overflow menu for when the tabs do not fit (they shrink and elide), a close affordance, and reordering by drag.
Elements: the input set
#include "gbui/widgets/elements.hpp"
The primitives a form is made of. Each holds no state, takes the value in and reports what the user did with it. select is one of these — it opens a popover, but so does a native <select>, and where a control draws its list is not what decides whether the control is a primitive.
| Component | Signature | Reports |
|---|---|---|
checkbox | (ui, input, id, checked, options) | true on the frame it was toggled |
radio | (ui, input, id, selected, options) | true when chosen; nothing when already selected |
toggle | (ui, input, id, on, options) | true on the frame it was flipped |
textInput | (ui, input, id, TextEditState&, options) | TextInputResult — changed, moved, submitted, cancelled, toggledReveal, and value/hasValue on a number |
textarea | (ui, input, id, TextareaState&, options) | TextEditResult — submitted is the modified Return |
field | (ui, input, id, options, control) | FieldResult — the id to focus when the caption was clicked |
slider | (ui, input, id, value, options) | {value, changed}, snapped to step |
progressBar | (ui, options) | nothing; a negative value draws the indeterminate form |
label | (ui, input, id, text, options) | the id to focus when it was clicked, or nothing |
hyperlink | (ui, input, id, label, options) | true on the frame it was followed |
Every one of them:
- is activated by a click or by Space/Return while focused;
- dims to 45% when
disabled, and recolours — opacity alone still reads as editable — and stops taking focus; - takes its size and shape from the active
Design, so a switch is 40×22 undergitbox()and 52×32 undermaterial()without any call site changing; - shows its focus ring only when focus arrived from the keyboard — the
:focus-visiblerule, described under focus and its ring.
Text and numbers
textInput is one box with an InputType, the shape <input> has: Text, Password and Number. HTML's email, url, tel and search are deliberately absent, because here they would change nothing — their whole effect is to pick a soft keyboard and hand the browser a validator, and this toolkit has neither.
All three support a placeholder, a leading icon, readOnly, disabled and invalid. Password draws bullets, with a reveal eye at the trailing edge that shows the text while it is held (revealToggle, on by default — a box you cannot read back is a box people mistype into). textInput is the exception to the ring rule above: a box that will swallow the next keystroke rings however focus arrived.
The pointer places its caret. A press puts the caret on the character boundary nearest the click, and holding and moving drags a selection out from there — both measuring the run exactly as the caret is drawn, so what is clicked is where it lands. A password box maps the click against its bullets and counts that many characters into the string, because the two are not the same number of bytes.
A number is edited as text, and that is the whole design. The state is a TextEditState like every other input's, because a caret needs a string and a number being typed is not yet a number: -, 1. and empty are all states a reader passes through. So while the box has the keyboard the text is the source of truth and nothing rewrites it underneath the caret; anything that could still become a number is accepted and anything else is refused outright.
result.value is the text read as a number, clamped even when the text is not — typing 500 into a box that stops at 60 leaves 500 on screen and returns 60 throughout. result.hasValue is false for an empty box, which is how a number input says it has no value. On blur the text is normalised from the clamped value, which is the one moment the two are allowed to disagree.
The wheel steps the value while the pointer is over the box, and Up and Down step it while it has the keyboard. Home and End do not go to the bounds — they belong to the caret, as they do in anything else that takes typing. The steppers go Sides, Stacked (the classic spin box) or None, and drop to Stacked automatically below stackedBelow pixels wide, so a box in a narrow column shows its value rather than two buttons.
Label and link
if (const auto target = label(ui, input, "f.name", "Repository", {.forId = "name"}))
interaction.focus(*target);Clicking a label focuses the control it names, exactly as <label for> does — and does not when that control is disabled or read-only. It returns the id rather than moving focus itself, which keeps the toolkit from mutating interaction state behind a component's back.
hyperlink follows href through openUrl on click or Return. An empty href reports the click and follows nothing, which is what a link that scrolls somewhere inside the application wants. chord requires modifiers for a click to count — for a link inside editable text, where a plain click has to place the caret instead. It underlines always by default, because colour alone is not a cue for everyone.
Pickers
#include "gbui/widgets/datePicker.hpp", timePicker.hpp, dateTimePicker.hpp, colorPicker.hpp
Each comes in two entry points rather than a flag: an inline one that is always open and owns its space, and a field that borrows space from a popover. They share the state and the drawing.
DatePickerResult datePicker(ui, input, id, selected, DatePickerState&, options);
DateFieldResult dateField(ui, input, id, selected, DatePickerState&, options);
TimePickerResult timePicker(ui, input, id, selected, TimePickerState&, options);
DateTimePickerResult dateTimePicker(ui, input, id, selected, DateTimePickerState&, options);
DateTimeFieldResult dateTimeField(ui, input, id, selected, DateTimeFieldState&, options);
ColorPickerResult colorPicker(ui, input, id, ColorPickerState&, options);
ColorPickerResult colorField(ui, input, id, ColorPickerState&, options);Date is three integers and Time is three more, deliberately not std::chrono::year_month_day — that type does the arithmetic inside, and putting it in the public surface would push a <chrono> include into every call site. Date::serial() and Time::secondsOfDay() are how two of them compare and step.
The calendar. Arrows move by a day, Page Up and Page Down by a month, and days outside the month or outside minimum/maximum are drawn and dimmed, not hidden — a grid with holes in it is harder to read than one with days you cannot pick. Today is marked with a dot rather than a ring, so it cannot be confused with the selection. The state carries what is being looked at, which is not the same as what is chosen — the same split a select makes between its highlight and its value.
The clock. Columns of hours and minutes rather than steppers, because a time is picked far more often than it is nudged: "quarter past two" is two glances, and two number fields make it two edits. Each column scrolls its selection into view, so opening on 23:45 does not show midnight. minuteStep coarsens it, and bounds that wrap midnight are handled as two ranges.
Twelve-hour is a display, not a value. Time::hour is always 0–23, so switching a picker to AM/PM keeps the same instant.
Formatting is a pattern, in the grammar every date library uses:
formatDate(date, "dd/MM/yyyy", locale);
formatTime(time, "HH'h'mm", clock);
formatDateTime(when, "EEE, d MMM 'at' h:mm a", calendar, clock);yyyy yy MMMM MMM MM M dd d EEEE EEE for dates, HH H hh h mm m ss s a for times; anything else is copied through, and text in single quotes is verbatim — which is how a pattern says "de" in Portuguese without the d being read as a day. The pattern decides the shape and CalendarLocale/ClockLocale decide the words, which is how one function covers Brazilian, American and ISO without a locale database.
The colour picker is a saturation/value square, a hue rail, an alpha rail over a chequerboard, a hex readout and optional swatches, each switchable off on its own. The square is built the way every picker on the web is: the pure hue behind, white-to-transparent across it and transparent-to-black down it — two gradients over a fill, three nodes rather than a shader. Its state is Hsv, not Color, for the reason on the core page.
Still missing: a text field that accepts a typed date, and relative phrasing ("3 days ago"), which needs its own words from the locale.
Rich editor
#include "gbui/widgets/richEditor.hpp"
RichEditorResult richEditor(Ui&, const Interaction&, id, RichDocument&,
RichEditorState&, const RichEditorOptions& = {});A block editor: paragraphs, headings, lists, quotes and code blocks, with bold, italic, underline, strikethrough, inline code and links over ranges within a block. Typing, splitting a block with Return and merging with Backspace all work, and the toolbar is the caller's to compose — defaultToolbar() is a starting point, and a Custom item runs a callback given the document and the state.
Marks are ranges over the text, not a tree of nested spans, which is the model every editor that survived contact with users ended up at — Quill's deltas, ProseMirror's marks. Nesting looks natural until a bold run and a link overlap by half, and then it is two trees that cannot both be right.
The BlockStyle toolbar item is a dropdown rather than one button per heading, because a block is a heading or a paragraph and a row of mutually exclusive buttons is a select wearing the wrong clothes.
Not finished, and the gaps are named rather than discovered: no images (the painter cannot decode one), no undo, no nested lists, and the caret moves by character and by block rather than by visual line — so a block that wraps is edited by a caret that does not know where the lines are.
Overlays
#include "gbui/widgets/overlays.hpp"
All of them sit in a layer above the content, are positioned by the placement engine rather than by the flex flow, and are anchored by tag: they ask the interaction layer where the anchor was last frame, which is the rectangle the user was pointing at and the only geometry available while the tree is being built.
The application owns whether they are open. A component that decided that for itself would need to keep state, and a toolkit that keeps state cannot rebuild its tree.
void tooltip(Ui&, const Interaction&, anchorId, std::string_view text, options);
Ui::Scope popover(Ui&, const Interaction&, id, anchorId, options);
bool menuItem(Ui&, const Interaction&, id, std::string_view label, options);
void menuSeparator(Ui&);
Modal modal(Ui&, const Interaction&, id, title, Vec2 position, options);
Ui::Scope modalActions(Ui&);
Drawer drawer(Ui&, const Interaction&, id, title, bool open, options);
ToastResult toast(Ui&, const Interaction&, ToastState&, float delta, options);The first five take a FloatingOptions — placement, gap, margin, flip, shift, bounds — as the base of their own options struct. toast does not: it is anchored to an edge rather than to a control, and the one case where it is anchored to a control says so with ToastPlacement::Anchored.
What closes one
FloatingOptions also carries dismissOnOutsideClick and dismissOnEscape, both on. Every open list on every platform closes when you press somewhere else, and a reader who has to find the control again to put it away will say the control is broken. Off is for the box that has to be dismissed on purpose — a form inside a popover, where a stray press would throw away typing.
A component that owns its own open state acts on these itself: select, and the three fields that open a picker. A caller driving a bare popover has to, because the popover has nowhere to keep the answer:
if (pressedOutside(input, {"branch.menu", "toolbar.branch"})) open = false;Both tags matter. The popover and the control that opened it are one thing to the reader, and closing on the anchor would shut the box on the way down so the toggle re-opened it on the way up — a menu that cannot be closed by clicking the button that opened it.
pressedOutside is the press, not the button being held: pointerDown() stays true until release, so the naive version fires on every frame of a held press. It also asks the two rectangles rather than the hit test, because "the press landed on no tagged node" reads as outside when somebody presses a decorative part of the box itself.
tooltip draws nothing when its anchor is not hovered, so the call sits unconditionally beside the control it describes. delay (0.4 s) is what stops a pointer dragged across a toolbar from flashing one per control; it needs an animator for the clock, and without one the tooltip shows at once.
Drawer
A panel from an edge — the shape behind a navigation menu on a narrow window, a filter pane, a details sidebar, a settings sheet on a phone.
auto sheet = drawer(ui, input, "filters", "Filters", showFilters,
{.side = DrawerSide::Right, .size = 260.0f});
if (sheet.result.dismissed) showFilters = false;
if (sheet.result.visible) {
// contents, written into the panel like any other container
}It takes open as a parameter, and it is the only floating thing here that does. Everywhere else, showing means building and hiding means not building; that works because appearing is instant. A drawer slides, and a component that stops being called cannot animate its own exit — the node is gone on the frame the caller stops asking for it, and there is nothing left to move off screen. So the flag goes in, and the component decides whether it is sliding in, open, sliding out, or gone. The application still owns the boolean, which is the rule that matters.
Closed and finished leaving, the body is a zero-sized clipped box out of the flow, so the call can sit unconditionally in the frame and anything written into it costs a few nodes and draws nothing.
modal (on) is the difference between a sheet that blocks the window — a backdrop, and the keyboard trapped inside — and a pane that sits beside it. Both are real: a navigation drawer on a phone is modal, a desktop inspector is not. One component with an option, for the reason this library keeps rediscovering. A non-modal drawer draws no backdrop even if you ask for one: dimming a window it does not block would tell the reader they cannot use something they can.
header and closeButton come off for a panel that brings its own chrome, and dismissOnBackdrop and dismissOnEscape come off for one that has to be answered rather than waved away.
popover bounds its own height: maxHeight = kAuto does not mean unbounded, it means the room actually available on the side it lands on, less the margin — so a popup that would run past the bottom of the window stops at it and scrolls inside instead. Pass a ScrollState* for that scrolling; null means it clips without moving. matchAnchorWidth is what a select's list wants.
Toast
Short-lived messages, stacked in a corner and gone on their own. The queue is ToastState, owned by the application like every other piece of state here — and it matters more than usual, because toasts are raised from anywhere: a network reply, a file watcher, a shortcut three screens away. A component that owned them would be a component with a global.
state.toasts.push({.kind = ToastKind::Error, .message = "Could not reach origin."});
…
toast(ui, input, model.toasts, delta); // once, near the end of the frameThe id is the whole of the grouping. push treats two entries with the same id as one and bumps a count instead of stacking a second copy, and an empty id is derived from the kind, the title and the message — so a retry loop reports "still offline ×40" rather than forty copies of one sentence. Set an id explicitly where messages that read alike are genuinely different events.
Where it goes. Six corners and edges, or ToastPlacement::Anchored against a tagged node using the same placement engine a popover uses. bounds says which rectangle the corners are measured from, so a stack can live inside a panel rather than over the window. Which way the stack grows is never a decision the caller makes — away from the edge it is anchored to, so a top-left stack reads downwards and a bottom-right one upwards.
A bottom stack does not measure itself to find its own bottom: the container is the whole column and justify puts the toasts at the end of it, which is correct on the first frame where arithmetic on last frame's height would not be.
group is a second axis, and a different one. It routes an entry to an outlet: a toast() call carrying a group shows only the entries in it, which is how a dialog reports into itself while the application's messages go to the corner.
The timer stops while it is being read. A message that vanishes mid-sentence was not delivered either, so the stack pauses while the pointer is over a toast or the keyboard is inside it — Toastify's behaviour, and what WCAG's "enough time" rule asks for. duration = 0 never expires, which is the right answer for anything the reader has to act on. Only what is on screen ages: an entry waiting behind maxVisible has not been read, so its clock has not started.
progress draws a bar draining across the foot, and only where there is a time to show — a sticky toast gets none, because a full bar under it would say the opposite of what is true. It dims while paused, which is the only way the pause is visible.
Accessibility. Each toast is its own live region: Status for info and success, which waits for a pause, and Alert for warning and error, which interrupts — because the next thing the reader was about to do will not work. The stack never takes the keyboard; only the × and the action are Tab stops, and each × is named after its message, since four buttons called "Dismiss" are four buttons nobody can tell apart. The progress bar carries no record at all: it is the timer, the timer already pauses whenever a reader is near it, and announcing it would be a second message nobody asked for.
Select, and the combobox it becomes
filter turns a select into a combobox: a box at the top of the open list that narrows it as the reader types. It is an option rather than a component of its own for the reason textInput absorbed two fields — everything that makes a select a select is unchanged by typing into it, and the two would be one control described twice.
SelectOptions options{.name = "Branch"};
options.filter = true;
const SelectResult result = select(ui, input, "branch", branches, model.branch,
model.branchList, options);
if (result.chosen) model.branch = *result.chosen;
if (result.focus) interaction.focus(*result.focus); // required when filteringSelectResult::focus is the caller's half. A filter box has to hold the keyboard to be typed into, so the control cannot keep it on the closed box — and a component here never moves focus behind the caller's back, the same contract label and field have. Without wiring it the filter still works, but only once clicked.
The highlight is an index into your list, never into the filtered view. That is the invariant filtering is easiest to get wrong: a highlight stored as "the third visible row" is a different option after every keystroke, and the reader commits something they never saw. chosen is in your numbering too.
The match is a case-insensitive substring and not a fuzzy score. Fuzzy matching is a ranking problem in a filter's clothing: it reorders the list under the reader and matches things they cannot see the reason for. A branch picker wants "the ones with nord in them", which has one answer.
Escape clears the filter before it closes the list — two meanings for one key in the order a reader wants them. Space types a space rather than committing, since a combobox that cannot have a space in its query cannot find feat/nord tuning; Return still commits. The arrows walk what is on screen, so they never step into rows the filter removed.
Accessibility. The filter box carries controls and activeDescendant, because that is where the keyboard is and a reader typing has to be told their letters are moving a highlight somewhere they are not. The match count is a Status live region — a filter that silently drops thirty-seven of forty rows has told a sighted reader everything and a screen reader nothing — and each row reports its place in what is shown, since "3 of 40" in a list narrowed to four is three lies in five words.
menuItem's check mark goes on the leading edge for a menu — where every desktop menu reserves a gutter for state — and on the trailing edge for a select, where the leading edge belongs to the labels being compared. It is focusable by default, which is right for a menu the keyboard should walk and wrong for a list whose owner drives the highlight.
modal takes the position and gives it back, so dragging survives the tree being rebuilt; pass an empty position on the first frame to have it centred. dismissed covers the close button, the backdrop and Escape.
Not built: toasts, and closing a menu on the next click outside — the application does that today.
Select
#include "gbui/widgets/select.hpp"
struct SelectState {
bool open = false;
std::optional<std::size_t> highlighted; // where the keyboard is, not the value
ScrollState list; // written by the component
};
SelectResult select(ui, input, id, items, selected, SelectState&, options); // {chosen}The state is the application's, like every other piece the toolkit reads. The important field is highlighted: walking a list is not choosing from it.
| Closed | Open | |
|---|---|---|
Up / Down | step the value | move the highlight, wrapping at both ends |
Home / End | — | first / last row |
Return, Space | open the list | commit the highlight, close |
Escape | — | close, value untouched |
Opening puts the highlight on the current value, so a list opened to look at and closed with Return changes nothing. The list keeps the highlighted row in view as it moves, scrolls past maxVisible (or maxListHeight, which wins when both apply — a row count cannot know how tall the window is), and the box keeps the keyboard: its rows are drawn and clickable but are not places Tab can land, so Tab leaves the control rather than walking into an open popup.
The filterable form is filter — see Select, and the combobox it becomes, where the keyboard moves into the filter box and the control hands the target back rather than moving focus itself.
More than one at a time
SelectOptions options;
options.multiple = true;
const SelectResult hit = select(ui, input, "branches", items, picked, state, options);
if (const auto row = hit.chosen) {
const auto at = std::find(picked.begin(), picked.end(), *row);
if (at != picked.end()) picked.erase(at);
else picked.push_back(*row);
}An option, not a multiSelect component beside it — the third time that argument has been settled here, after textField/numberField → textInput and combobox → select.filter. Everything that makes a select a select is unchanged by how many rows it keeps: the value is still the caller's, the highlight is still separate from it, the list is still a popover. What changes is that a row toggles instead of replacing, that the list stays open so a second row can be taken, and that the closed box has more than one thing to say.
chosen is the row that was toggled, not the new value: the component says what happened and the caller decides what its set becomes, exactly as checkbox reports a press rather than writing a bool.
multiple is the interaction and the caller's container is the value, which is why the overload taking a std::vector<std::size_t> exists rather than a second component. The single-value form still works with multiple on and simply holds at most one.
Below summariseFrom (3) the closed box lists the labels, because "main, feat/a" is more use than "2 selected"; at or above it, the count — a box listing nine branch names is a box whose own label has gone. The list carries aria-multiselectable, without which a multi-select list is announced exactly like a single-select one and the first choice appears to have thrown the previous one away.
Menus
MenuResult menu(Ui&, const Interaction&, id, anchorId, entries, MenuState&, options);
MenuResult contextMenu(Ui&, const Interaction&, id, Vec2 at, entries, MenuState&, options);menuItem has always been the row; these are the box the rows go in, and the keyboard that walks them. Before them every caller wired a popover to a list by hand — which meant every caller got the geometry right and the keyboard wrong, because the geometry is visible and the keyboard is not.
One component, two anchors. A dropdown hangs off a control and a context menu appears where the pointer was; that is the only difference, so anchoring to a zero-sized rectangle at a point is all the second one is. It flips off the bottom of the window and shifts off its right through the same placement engine, with no second path to get wrong.
A menu is one Tab stop, not one per row. Nine commands that each took the keyboard would be nine Tab presses to cross one menu. The arrows move the highlight and skip separators and disabled rows — a highlight that stopped on either looks stuck, and Enter on it does nothing — Home and End reach the ends, Enter and Space activate, and Escape asks to close.
if (input.secondaryClicked("row.7")) {
menuAt = input.pointer(); // where it was *then*
menuOpen = true;
}Interaction::secondaryClicked is the right button, and it exists for this and nothing else yet. It is its own press-and-release pair and deliberately shares none of the primary path: a right-click moves no focus and starts no drag, so a slider does not jump when somebody right-clicks it. Keep the pointer position from the frame the menu was asked for — one that re-reads the live position walks away from itself as the reader moves towards it.
Segmented, breadcrumbs, pagination
std::optional<std::size_t> segmented(Ui&, const Interaction&, id, segments, selected, options);
BreadcrumbsResult breadcrumbs(Ui&, const Interaction&, id, trail, options);
PaginationResult pagination(Ui&, const Interaction&, id, current, pageCount, options);segmented is a select whose options are worth the room. Two or three — unified and split, day and week and month — read faster as a row than as a box that has to be opened, and the reader can see what they did not pick. Past about five, use select: a strip of nine is a row of tiny targets that wraps.
It is a radio group and not a tab strip, though it looks identical to one. Tabs show a panel: the strip and the panel are one widget. This changes a value, and the thing it changes may be nowhere near it — announced as tabs, a reader is told to expect a panel that never comes. The arrows move and choose in one press, because a radio group that needed a second press to commit is not how any platform's works.
breadcrumbs says where you are as much as it navigates, which is why the last crumb is not a link and takes no focus: a link to the page you are on is a control that does nothing. It carries current — ARIA's aria-current, not selected, because the reader is not being told they picked it.
Given maxVisible, the middle collapses to an ellipsis and the ends stay: those are the two a reader needs — where they are, and the root they can get back to. The count is in the ellipsis's own name, because "…" alone is a button whose whole meaning is the number it stands for.
pagination always keeps the first and last page, whatever the current one is: "jump to the end" is the second most common thing anybody does with a paginator, and hiding it makes them press next forty times. A gap of exactly one page is drawn as the page — an ellipsis standing in for a single button is strictly worse than the button. The current page takes current and no press.
For data a reader scans rather than navigates, virtualList is the better answer: it builds only the visible rows of fifty thousand and nobody has to decide what a page is. Reach for a paginator when the underlying query is paged anyway.
Accordion
AccordionResult accordion(Ui&, const Interaction&, id, sections, AccordionState&, options);Not tabs, though both hide all but one thing: tabs are one of a set and the set is always visible, while an accordion can have none open or all of them, and the sections it is not showing take no room. Use tabs when the reader is switching between views of the same size, and this when they are opening a long thing to read it.
A section's body is a callback, called only while it is open — a closed section costs nothing rather than being built and hidden. Every header is a button carrying expanded, so a reader is told what a press will do before they make it.
exclusive is off by default, which is the less obvious choice: an accordion whose sections close each other cannot be used to compare two of them, and a reader who opens the second and loses the first will open both again one at a time. Turn it on when the sections are long enough that two on screen would be worse.
The arrows walk the headers and stop at the ends rather than wrapping, unlike a radio group: a stack of sections has a visible top and bottom, and wrapping from the last to the first reads as the list having scrolled.
Charts
lineChart, barChart, scatterChart, heatmap, candlestickChart and donutChart have a page of their own: charts.
Icons
enum class Icon { Archive, Bold, ChartPie, Check, ChevronDown, …, X, Count };
std::string_view iconPath(Icon);
std::optional<Icon> iconFromName("git-branch");Forty Lucide glyphs, generated by tools/generate_icons.py; do not edit the table by hand. See Icons.
Not built yet
Named rather than half-built: a tree view (the branch sidebar), a split pane with draggable dividers, a breadcrumb, a toast, a marquee, an empty state, an avatar, and a code and diff view with syntax highlighting — that last one is a project of its own and is the honest reason a full migration off a web stack is a year rather than a quarter.