chancymeerkat · buttons.html
component exploration · v0.2 · copy / download / upload / save / overflow
<!-- icon-only ghost md -->
<button class="btn btn-ghost btn-icon btn-md"
data-tip="copy"
aria-label="copy">
<svg class="icon" viewBox="0 0 24 24">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<!-- size swap: .btn-sm | .btn-md | .btn-lg -->
<!-- variant swap: .btn-ghost | .btn-filled -->
<!-- additional icons (CHATer action bar palette) -->
<!-- retry: <polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4"/> -->
<!-- regenerate: <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> -->
<!-- thumbs-up: <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/> -->
<!-- thumbs-dn: <path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/><path d="M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/> -->
<!-- code: <polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/> -->
<!-- transcript: <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/> -->
<!-- import: <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="12 18 12 12"/><polyline points="9 15 12 12 15 15"/> -->
<!-- close: <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> -->
/* required CSS */
.btn-icon.btn-sm { width: 28px; padding: 0; }
.btn-icon.btn-md { width: 34px; padding: 0; }
.btn-icon.btn-lg { width: 40px; padding: 0; }
.icon { width: 14px; stroke: currentColor; fill: none; } | class | height | width (icon) | icon size |
|---|---|---|---|
| .btn-sm | 28px | 28px | 14px |
| .btn-md | 34px | 34px | 14px |
| .btn-lg | 40px | 40px | 16px |
data-tip="label" on any .btn renders via ::after. Reads the attribute at paint time. No DOM nodes, no JS, no dependency. Position defaults to above; add data-tip-pos="below" to flip.
touch caveat -- hover never fires on mobile. Icon-only buttons on touch must have visible text or rely on context. Don't hide critical labels behind tooltips on mobile paths.
All icons: viewBox="0 0 24 24" · stroke-width: 1.6 · stroke-linecap: round · stroke: currentColor · fill: none. Color inherits from button state -- no separate icon theming. Source-compatible with Feather, Lucide, Heroicons Outline.
| data-tip | used in |
|---|---|
| retry | user bubble action bar |
| regenerate | assistant bubble action bar |
| thumbs up | assistant bubble action bar (.btn-toggled) |
| thumbs down | assistant bubble action bar (.btn-toggled) |
| code | view switcher (json view) |
| transcript | view switcher (transcript view) |
| import | file import trigger |
| close | dialogs, panels |
required Icon-only buttons have no visible label. Always add aria-label="action" or a .sr-only span. The CSS tooltip is not exposed to screen readers -- it's decoration only.
:focus-visible ring: 2px --accent at 2px offset. Keyboard nav works out of the box. Don't suppress focus outlines.
<!-- ghost with mutable label -->
<button class="btn btn-ghost btn-md"
onclick="flashLabelSuccess(this,'copy','copied!')">
<svg class="icon" viewBox="0 0 24 24">...</svg>
<span class="btn-label">copy</span>
</button>
<!-- accent primary -->
<button class="btn btn-accent btn-md">
<svg class="icon">...</svg>
save
</button>
<!-- danger -->
<button class="btn btn-danger btn-md">
<svg class="icon">...</svg>
delete
</button> | variant | use when | avoid when |
|---|---|---|
| .btn-ghost | secondary / toolbar / non-destructive | primary CTA -- too subtle |
| .btn-filled | slightly elevated, dense UIs | isolated CTAs -- use accent |
| .btn-accent | one primary action per context | multiple per row -- creates noise |
| .btn-danger | delete, clear, reset | anything reversible without confirmation |
Wrap text in <span class="btn-label"> when it changes on action. Target btn.querySelector('.btn-label') -- never rebuild the element.
function flashLabelSuccess(btn, from, to) {
const l = btn.querySelector('.btn-label');
if (l) l.textContent = to;
btn.classList.add('success');
setTimeout(() => {
btn.classList.remove('success');
if (l) l.textContent = from;
}, 1400);
} Gap is 0.4rem on the flex container. Icon has flex-shrink: 0 so it never collapses on long labels. Never add margin directly to the SVG -- gap owns spacing.
click to trigger
<!-- dual-icon swap pattern -->
<button class="btn btn-ghost btn-icon btn-md"
onclick="flashIconSuccess(this)">
<svg class="icon copy-icon" viewBox="0 0 24 24">
<!-- copy paths -->
</svg>
<svg class="icon check-icon"
style="display:none" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
function flashIconSuccess(btn) {
btn.classList.add('success');
btn.querySelector('.copy-icon').style.display = 'none';
btn.querySelector('.check-icon').style.display = 'block';
setTimeout(() => {
btn.classList.remove('success');
btn.querySelector('.copy-icon').style.display = 'block';
btn.querySelector('.check-icon').style.display = 'none';
}, 1400);
} Wire the real copy before triggering the flash. Always handle the rejection -- clipboard access can be denied.
async function copyText(text, btn) {
try {
await navigator.clipboard.writeText(text);
flashIconSuccess(btn);
} catch {
// execCommand fallback (deprecated, broad support)
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
flashIconSuccess(btn);
}
} | duration | feel | use for |
|---|---|---|
| 800ms | snappy, forgettable | frequent repeated actions |
| 1400ms | confident, readable | default -- copy, save confirm |
| 2500ms | emphatic | destructive undo window |
Uses !important on border, color, bg so it works regardless of base variant. Add/remove via classList only -- never inline styles, or the removal won't clean up correctly.
.btn.success {
border-color: var(--success) !important;
color: var(--success) !important;
background: rgba(74,222,128,.07) !important;
} <div class="toolbar">
<button class="btn btn-ghost btn-icon btn-md" data-tip="copy">...</button>
<button class="btn btn-ghost btn-icon btn-md" data-tip="download">...</button>
<div class="toolbar-overflow">
<button class="btn btn-ghost btn-icon btn-md"
onclick="toggleOverflow('menu-id', this)">
<!-- dots icon -->
</button>
<div class="overflow-menu" id="menu-id">
<button class="overflow-item">upload</button>
<button class="overflow-item">save as</button>
<button class="overflow-item danger">delete</button>
</div>
</div>
</div>
/* border model */
.toolbar .btn { border: none; border-right: 1px solid var(--line); }
.toolbar .btn:first-child { border-radius: 5px 0 0 5px; }
.toolbar .btn:last-child { border-radius: 0 5px 5px 0; border-right: none; } Buttons inside .toolbar strip all border/radius. The container owns the outer border. Only a right-side divider remains per button, removed on :last-child. Adding or removing buttons is safe -- caps are automatic via pseudo-selectors.
A single document-level click listener closes all open menus when the click lands outside any .toolbar-overflow or trigger. Lightweight for single-toolbar pages. For pages with many toolbars, use a WeakMap keyed on trigger elements instead of string ID matching.
| position | item type | why |
|---|---|---|
| top | neutral (upload, rename) | easy reach, low risk |
| middle | secondary saves | expected, not urgent |
| bottom | destructive | distance = friction = safety |
<!-- text trigger: no icon -->
<div style="position:relative;display:inline-block">
<button class="btn btn-ghost btn-md"
onclick="toggleOverflow('id', this)">
<span>menu</span>
<!-- caret optional -->
</button>
<div class="overflow-menu" id="id">
<button class="overflow-item">copy</button>
<button class="overflow-item danger">delete</button>
</div>
</div>
/* collapse pattern */
@media (max-width: 480px) {
.toolbar { display: none; }
.mobile-menu { display: inline-block; }
} | approach | how | best for |
|---|---|---|
| CSS media query | display:none toolbar, show trigger | static toolbars, known breakpoints |
| ResizeObserver | JS watches container width, swaps classes | iframe panes (DIFFer, CSVer) -- unknown container |
| Container queries | @container + container-type | future-proof, modern browsers only |
Min comfortable target: 44x44px (Apple HIG) / 48x48px (Material). .btn-md at 34px falls short. Extend invisibly without changing layout:
.btn::before {
content: '';
position: absolute;
inset: -6px; /* extends hit area to ~46px */
} Skipping the hamburger avoids icon recognition overhead. The word menu is self-documenting. In a dense monospace UI, text labels are already the visual language -- icons would break dialect. The subtle caret signals expandability without requiring icon literacy.
<!-- disabled -->
<button class="btn btn-ghost btn-md" disabled>copy</button>
<!-- loading -->
<button class="btn btn-ghost btn-md" disabled>
<svg class="icon"
style="animation:spin 0.9s linear infinite"
viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
copying...
</button>
<!-- success -->
<button class="btn btn-ghost btn-md success">
<svg class="icon" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"/>
</svg>
copied!
</button>
<!-- toggled (persistent state, e.g. thumbs up/down) -->
<button class="btn btn-ghost btn-icon btn-md btn-toggled">
<svg class="icon" viewBox="0 0 24 24">...</svg>
</button>
.btn-toggled {
border-color: var(--accent) !important;
color: var(--accent) !important;
background: rgba(251,191,36,.1) !important;
}
@keyframes spin { to { transform: rotate(360deg); } } | state | CSS mechanism | interactive | aria |
|---|---|---|---|
| default | -- | yes | -- |
| hover | :hover | yes | -- |
| disabled | :disabled attr | no | aria-disabled="true" |
| loading | disabled + spinner | no | aria-busy="true" |
| success | .success class | briefly no | aria-live="polite" on container |
| toggled | .btn-toggled class | yes | aria-pressed="true" |
The native disabled attribute blocks double-submits at the DOM level during async operations. No JS guard needed. Re-enable with btn.disabled = false -- don't toggle a class, the attribute is what matters.
async function handleSave(btn) {
btn.disabled = true;
// swap to spinner icon here
await saveData();
btn.disabled = false;
flashSuccess(btn);
} "icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
] hover a bubble to reveal actions
<div class="bubble-wrap" data-idx="N">
<div class="bubble bubble-ai">...</div>
<div class="msg-action-bar">
<button class="btn btn-ghost btn-icon btn-sm"
data-action="copy" data-tip="copy" aria-label="copy">...</button>
<button class="btn btn-ghost btn-icon btn-sm"
data-action="thumbs-up" data-tip="good" aria-label="good">...</button>
<button class="btn btn-ghost btn-icon btn-sm"
data-action="thumbs-down" data-tip="not good" aria-label="not good">...</button>
<button class="btn btn-ghost btn-icon btn-sm"
data-action="regenerate" data-tip="regenerate" aria-label="regenerate">...</button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger"
data-action="delete" data-tip="delete" aria-label="delete">...</button>
</div>
<div class="bubble-meta" id="meta-N"></div>
</div> Each .bubble-wrap carries data-idx matching its position in
history[] and metadata[]. A single delegated listener on
#messages handles all action buttons via e.target.closest('[data-action]').
Never attach per-bubble listeners — this breaks on history restore.
During streaming, .bubble-wrap.streaming hides the action bar via
display:none. Remove .streaming in onDone(). This prevents
copy/edit/retry on partial responses.
Thumbs use .btn-toggled (not .success) because the
state persists until explicitly changed. Toggle is mutual: selecting "up" clears "down" and
vice versa. Selecting the active thumb again clears it (toggle-off).
Action bar starts at opacity: 0 and fades in on
.bubble-wrap:hover and :focus-within. Height is reserved even when
invisible so layout doesn't shift on hover. Keyboard users (Tab into a bar button) also reveal
it via :focus-within.