Loading page…
Loading page…
Buttons that replace their own contents instead of spawning a second control, and buttons that carry no words at all. An action-replacement button holds one slot: the label and glyph trade places as state moves — watch to hidden, armed to sent, locked to open. Confirmation buttons make the commitment physical: drag the thumb across, or hold until the fill lands. Icon buttons drop the label entirely and carry their name in aria-label.
One control, one slot. Click any specimen: the button cycles to the next item and the contents swap in place. Four variants match the Button family's levels of commitment.
The decisive swap — arm, then fire.
Default weight. Sits inside cards and rails.
Hollow hull; reads as optional.
Recedes until the pointer finds it.
import { ActionSwapButton, type ActionSwapItem } from "@coldharbor/webui";
import { Eye, EyeOff } from "lucide-react";
const items: ActionSwapItem[] = [
{ id: "hidden", label: "Show balance", icon: <Eye className="h-4 w-4" /> },
{ id: "shown", label: "Hide balance", icon: <EyeOff className="h-4 w-4" /> },
];
<ActionSwapButton items={items} variant="secondary" />--ch-primary--ch-primary-fg--ch-surface--ch-border--ch-fg--ch-fg-mutedsm for dense rails, md as the default, lg for ceremony, icon for a square glyph-only swap. size="icon" sets iconOnly, so give each item an ariaLabel — the label never renders.
Label drops out; ariaLabel names the state.
<ActionSwapButton items={items} size="lg" />
{/* Icon-only: no visible label, so every item needs an ariaLabel */}
<ActionSwapButton
size="icon"
variant="outline"
items={[
{ id: "locked", label: "Locked", icon: <Lock className="h-4 w-4" />, ariaLabel: "Vault locked — unlock" },
{ id: "open", label: "Open", icon: <Unlock className="h-4 w-4" />, ariaLabel: "Vault open — lock" },
]}
/>--ch-primary--ch-primary-fg--ch-surface--ch-border--ch-fg--ch-fg-mutedThree ways the contents trade places. Click each one to watch the exchange; all three collapse to a plain crossfade under reduced motion.
Default. Old label defocuses out, new one focuses in — no vertical travel.
Label and icon roll up through the mask, like a departure board.
Letter by letter, left to right. Needs a plain string; falls back to roll otherwise.
{/* animation: "blur" | "roll" | "cascade" */}
<ActionSwapButton items={items} animation="cascade" />--ch-primary--ch-primary-fg--ch-surface--ch-border--ch-fg--ch-fg-mutedThe two halves of the swap, standalone. Both key off value: change it and the contents exchange. Use them to fit the idiom into a control the button family does not cover — a table cell, a nav item, a bespoke trigger. ActionSwapText animates its own width, so labels of different lengths never jump.
Press it: width tweens between labels, and cascade splits the string per letter.
Press it: one glyph slot, popLayout keeps the outgoing icon out of the flow.
import { ActionSwapIcon, ActionSwapText } from "@coldharbor/webui";
<button type="button" onClick={next}>
<ActionSwapIcon value={state} animation="blur" className="h-4 w-4">
{state === "syncing" ? <RefreshCw className="h-4 w-4" /> : <Check className="h-4 w-4" />}
</ActionSwapIcon>
<ActionSwapText value={state} animation="roll">
{state === "syncing" ? "Syncing" : "Synced"}
</ActionSwapText>
</button>--ch-primary--ch-primary-fg--ch-surface--ch-border--ch-fg--ch-fg-mutedDrag the thumb to the far end. Past threshold (0.82 of the track) it snaps home, the fill sweeps, the chevron redraws into a check, and onComplete fires; after resetDelay the track rearms. Keyboard: focus the thumb and press Enter or Space.
threshold 0.82, resetDelay 1200ms.
threshold 0.55 — commits at just past half the track.
import { SlideActionButton } from "@coldharbor/webui";
<SlideActionButton
completeLabel="Broadcast"
threshold={0.82}
resetDelay={1200}
onComplete={() => broadcast(psbt)}
>
Slide to send
</SlideActionButton>--ch-primary--ch-primary-fg--ch-bg--ch-fg--ch-focusPress and keep pressing. The fill runs for holdDuration, then onHoldComplete fires; let go early and it retreats. Label runs three phases — idle, holdingLabel, completeLabel. Space or Enter holds it from the keyboard.
Fill rises from the waterline; 1600ms.
type="horizontal" sweeps left to right; 2400ms for a heavier commitment.
import { HoldActionButton } from "@coldharbor/webui";
<HoldActionButton
type="vertical"
holdDuration={1600}
holdingLabel="Keep holding"
completeLabel="Wallet erased"
onHoldComplete={() => wipe()}
>
Hold to erase wallet
</HoldActionButton>--ch-primary--ch-primary-fg--ch-bg--ch-fg--ch-focusHover or focus it: the accent block runs the width of the hull and a squall of chevrons drifts across while the label slips away. A departure control — leaving for an explorer, a next leg, an outbound flow. Deliberately dark in either theme; re-skin through className and accentClassName.
Hover or tab to it — the accent expands on hover and focus alike.
import { ExpandingArrowButton } from "@coldharbor/webui";
<ExpandingArrowButton onClick={() => router.push("/explorer")}>
Open explorer
</ExpandingArrowButton>--ch-primary--ch-primary-fg--ch-bg--ch-fg--ch-focusA rail of icons that grows its labels when the pointer or focus arrives. Collapsed it is a row of glyphs; expanded it reads as a toolbar. Items carry an optional badge and shortcut, and a shared highlight slides between them.
Hover the rail, or tab into it — expandOnHover and expandOnFocus are both on.
Badge pins to the corner while collapsed; shortcut only surfaces expanded, ≥sm.
expanded pins it; the bar stops listening to hover state.
import { ExpandableActionBar, useExpandableActionBar } from "@coldharbor/webui";
const items = [
{ id: "send", label: "Send", icon: <Send className="h-4 w-4" /> },
{ id: "receive", label: "Receive", icon: <QrCode className="h-4 w-4" /> },
];
// The hook keeps expanded + activeId together and hands back the active item.
const bar = useExpandableActionBar(items);
<ExpandableActionBar
items={items}
expanded={bar.expanded}
onExpandedChange={bar.setExpanded}
activeId={bar.activeId}
onAction={(item) => bar.setActiveId(item.id)}
/>--ch-primary--ch-primary-fg--ch-surface--ch-border--ch-fg--ch-fg-mutedButton size="icon" is a square 32×32 target with no label. Primary, secondary and outline carry the .ch-cut treatment — one corner notched diagonally, sized by --ch-cut-size (0.5rem at this size) with the border tracing the cut. Ghost stays uncut: it is a plain glyph target.
Chamfered. One per screen — the QR that starts a receive.
Chamfered. Raised surface, strong border.
Chamfered. Transparent hull.
No cut — a plain glyph target for toolbars.
Four glyph targets, each with its own name. Ghost keeps the row quiet.
Pointer events drop and opacity halves — same as every other size.
import { Button } from "@coldharbor/webui";
import { QrCode } from "lucide-react";
<Button variant="outline" size="icon" aria-label="Show QR code">
<QrCode aria-hidden className="h-4 w-4" />
</Button>--ch-primary--ch-primary-fg--ch-surface-raised--ch-border-strong--ch-fg--ch-fg-muted--ch-focus--ch-radius-cutAn icon button has no accessible name of its own — a glyph is not text. Every one carries aria-label, and the glyph itself is aria-hidden so it never leaks into the name. Where the state changes, the name changes with it; where a tooltip already labels the control, the label and the tooltip must say the same thing.
Balance hidden vs shown: the label follows the action the press performs.
Two identical glyphs, two different names — 'Copy address' is not 'Copy txid'.
{/* The label names the action, not the picture. */}
<Button size="icon" aria-label={hidden ? "Show balance" : "Hide balance"}>
{hidden ? <Eye aria-hidden className="h-4 w-4" /> : <EyeOff aria-hidden className="h-4 w-4" />}
</Button>
{/* ActionSwapButton takes the same name through the item. */}
<ActionSwapButton size="icon" items={[{ id: "locked", label: "Locked", icon: <Lock />, ariaLabel: "Unlock vault" }]} />--ch-primary--ch-primary-fg--ch-surface-raised--ch-border-strong--ch-fg--ch-fg-muted--ch-focus--ch-radius-cutA swap, a hold and an icon rail together under the layer.
--ch-primary--ch-primary-fg--ch-surface-raised--ch-border-strong--ch-fg--ch-fg-muted--ch-focus--ch-radius-cut