9 Icon Button CSS Patterns — Press, Drag, Expand
An icon button css pattern trades the label for a picture, so the meaning has to survive for anyone who never sees it.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Speed dial that turns 45 degrees to hand out actions
- 02 Toolbar FABs that read which way you scroll
- 03 Icon ripple spreading from the exact press point
- 04 Favorite star that draws its line, then fills
- 05 Theme button where the sun and moon trade places
- 06 Close button with a ring that keeps circling
- 07 Search FAB that unfolds into an input
- 08 Help button that opens a chat launcher
- 09 FAB that follows your long-press drag
The order runs from the widest job a button can hold, down to the smallest. First come the two that own the whole screen: one press splits into three actions (01), and a row of tools steps aside while you read (02). The next four differ only in how they answer a press — a wave starts at the pixel you touched (03), a star traces its outline before it fills (04), a sun and a moon swap seats (05), and a close mark spins a ring under the pointer (06). The last three change the button itself: an icon widens into a text field (07), a launcher drags a chat panel up with it (08), and a long press hands you the button to carry somewhere else (09).
01Speed dial that turns 45 degrees to hand out actions
One create button cannot hold writing, photos, and voice at the same time unless it splits after the press. The plus mark turns 45 degrees into a close mark while three labeled buttons rise 40ms apart, and whether it is open or shut is written to aria-expanded rather than guessed from a class name. Escape closes it, and 9 of the 24 preview frames carry movement.
.sd__sub {
transform: translateY(12px) scale(0.6);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1),
opacity 140ms linear;
}
.sd.is-open .sd__sub {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.sd__item:nth-child(2) .sd__sub { transition-delay: 40ms; }
.sd__item:nth-child(3) .sd__sub { transition-delay: 80ms; }
.sd__plus { transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.sd.is-open .sd__plus { transform: rotate(45deg); }
02Toolbar FABs that read which way you scroll
Tools that cover the paragraph you are reading stop being tools. This part compares scrollTop against its last value: a rising number means you are going down, so the bar of three round buttons leaves by translateY(150%), and a falling number brings it straight back. A 2px threshold keeps a trembling finger from flipping the bar on every pixel.
var tbLast = tbView.scrollTop;
tbView.addEventListener('scroll', function () {
var now = tbView.scrollTop;
var down = now - tbLast > 2;
var up = tbLast - now > 2;
tbLast = now;
if (down && !tb.classList.contains('is-hidden')) {
tb.classList.add('is-hidden');
} else if (up && tb.classList.contains('is-hidden')) {
tb.classList.remove('is-hidden');
}
});
document.getElementById('tb-top').addEventListener('click', function () {
tbView.scrollTo({ top: 0, behavior: 'smooth' });
});
03Icon ripple spreading from the exact press point
A wave that always starts dead center throws away the one thing the press knew, which is where it landed. pointerdown measures the point against the button's getBoundingClientRect(), writes it into --x and --y, and a 16px circle grows from there by scale(9) over 600ms before it is removed. Nothing in that path touches layout, since only transform and opacity change.
.rp__wave,
.rp__seed {
position: absolute;
left: var(--x);
top: var(--y);
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.5);
transform: translate(-50%, -50%) scale(0);
pointer-events: none;
}
@keyframes rp-grow {
0% { transform: translate(-50%, -50%) scale(0); opacity: 0.55; }
70% { opacity: 0.2; }
100% { transform: translate(-50%, -50%) scale(9); opacity: 0; }
}
04Favorite star that draws its line, then fills
A star that turns yellow in an instant reports the result but never shows the act. The outline travels from stroke-dashoffset: 100 to zero across 460ms, and the filled copy sits behind a 0ms 460ms delay so it appears the moment the line closes, not while the line is still being drawn. The preview moves in 16 of its 24 frames, and the card background takes the same yellow at that instant.
.st__draw path {
fill: none;
stroke: #17141a;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset 460ms ease-out;
}
.st__fill {
opacity: 0;
transition: opacity 0ms 460ms;
}
.st.is-on .st__draw path { stroke-dashoffset: 0; }
.st.is-on .st__fill { opacity: 1; }
05Theme button where the sun and moon trade places
Cross-fading a sun into a moon produces a frame where both are half there, and that ghost is exactly what a poster grab picks up. Both icons share one grid cell and swap through transition: opacity 0ms, while the card and the day/night label behind them are cut by steps(1, end); rotation and scale keep their easing and stay smooth. It carries the second-largest single-frame change of the nine, at 17.81% of the stage.
.tm__sun,
.tm__moon {
position: absolute;
grid-area: 1/1;
transition: opacity 0ms, transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__moon { opacity: 0; transform: rotate(-90deg) scale(0.4); }
.tm.is-dark .tm__sun { opacity: 0; transform: rotate(90deg) scale(0.4); }
.tm.is-dark .tm__moon { opacity: 1; transform: rotate(0deg) scale(1); }
.tm.is-dark .tm__disc { transform: rotate(180deg); }
06Close button with a ring that keeps circling
A close mark small enough to sit on a chip is also small enough to look like decoration. A dashed ring wraps it and turns once every 900ms, but only while the pointer hovers or :focus-visible lands, so a keyboard visitor is handed the same signal as a mouse visitor. Nothing but the ring moves here, which is why the card is held to 252px and the button pushed out to 56px.
.cl__ring {
position: absolute;
inset: 0;
border: 3px dashed currentColor;
border-radius: 50%;
opacity: 0.35;
}
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring {
opacity: 1;
animation-name: cl-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
07Search FAB that unfolds into an input
A magnifier can only become a text field if the widths in between actually exist. --w is registered through @property as a <length>, so the pill flows from 48px to 264px instead of snapping, and focus enters the field with preventScroll once the width lands. Escape empties the field and folds the pill back to a circle.
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 48px;
}
.se__pill {
--w: 48px;
box-sizing: border-box;
width: var(--w);
height: 48px;
border-radius: 999px;
transition: --w 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
border-color 140ms linear;
}
08Help button that opens a chat launcher
A contact link that navigates away costs the reader the screen they were standing on. Here, a 148px panel lifts into place above the FAB with translateY, the line you send becomes a real bubble in the log, and the spot where the answer arrives is announced through aria-live. It is the biggest arrival in this set, changing 26.31% of the stage in one frame.
function chAdd(text, who) {
var p = document.createElement('p');
p.className = 'ch__bubble ch__bubble--' + who;
p.textContent = text;
chLog.appendChild(p);
return p;
}
chFab.addEventListener('click', function () {
ch.classList.toggle('is-open');
var open = ch.classList.contains('is-open');
chFab.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) window.setTimeout(function () {
chInput.focus({ preventScroll: true });
}, 240);
});
09FAB that follows your long-press drag
Sometimes the floating button parks itself on the exact sentence you wanted to read. A press held past 350ms flips it into grab mode, setPointerCapture keeps the move events coming even when the finger slides off the circle, and the button rides translateX under it. Releasing measures which half of the stage you are on and snaps to that edge, while a press that never reaches 350ms stays an ordinary click.
function dgSnap(x) {
var side = dgSide(x);
var box = dgScreen.getBoundingClientRect();
var maxX = box.width - dgFab.offsetWidth - 8;
dgFab.style.transform = 'translateX(' + (side === 'left' ? 0 : maxX) + 'px)';
}
dgFab.addEventListener('pointerdown', function (e) {
dgHold = window.setTimeout(function () {
dgGrab = true;
dgFab.classList.add('is-grab');
dgScreen.classList.add('is-holding');
try { dgFab.setPointerCapture(e.pointerId); } catch (err) { }
}, 350);
});
dgFab.addEventListener('pointerup', function (e) {
dgClearHold();
dgScreen.classList.remove('is-holding');
if (!dgGrab) return;
dgGrab = false;
dgFab.classList.remove('is-grab');
dgSnap(e.clientX);
});
Where it breaks — the trap
The break that shows up first is a control with no name at all. Six of the nine hold nothing but a glyph, and a glyph with no accessible name is read out as "button" and nothing else. Every one of them therefore got an aria-label, and the ones that toggle rewrite that label to match the state: the speed dial says "Open the create menu" while shut and "Close the create menu" while open. The theme button in 05 follows the same rule from the other side — its name describes what the next press will produce, not what you are currently looking at.
The second is a fade standing where a cut belongs. The first build of 05 cross-faded the sun into the moon, and the 24-frame poster pass grabbed the one frame where both were half visible. Cutting visibility into a single frame killed the ghost and immediately created a second problem, because a pure cut leaves only two or three frames with anything in them. Widening the disc's 180-degree turn on both sides of the cut brought the item back to 10 moving frames.
The third is the exit stealing the poster. Item 01 is a story about opening, but three helper buttons leaving together changed more pixels than three arriving, so an empty corner won the first impression. Pulling the exits apart to 78, 84, and 90 percent and shrinking each one on the way out left an open frame standing. Item 06 failed in the opposite direction: a turning ring is the whole idea, so only 1.83% of the stage ever changes — the smallest figure in the set, and the reason its card and button were resized until the ring could carry a thumbnail.
Narrow screens break things last. Every part occupies a 480×300 slot on the page and drops to 320×200 on a phone, which leaves 174px of usable height once the stage padding is taken out, so at 320px each spacing value goes down and never up. The helper buttons in 01 give up their 168px minimum width for 140px, the chat panel in 08 goes from 148px to 112px, and 09 refuses to store its snap target as a number at all — it recomputes stage width minus button width on every release, because the button is a different size down there. All nine sets live in one folder whose archive password is khf267ta, and the React build packed beside the vanilla one behaves the same way on the same numbers.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce, every loop halts on a result instead of a blank: the speed dial rests open, the star rests drawn and filled, the theme button rests on the moon, and the draggable one rests grabbed at the left edge. Someone who switched motion off still has to be able to tell which state the button is in.
For icon buttons, the job splits cleanly into a name and a state. The name is aria-label; expanding goes to aria-expanded (01, 07, 08), pressed or not goes to aria-pressed (04, 05), and 08 adds aria-live for a reply that arrives without being asked for. Focus needs a guard as well, which is why 07 and 08 both call focus({ preventScroll: true }) — these parts run inside iframes on the grid page, and an unguarded call yanks the parent article along with it. In 06, the hover rule and the :focus-visible rule are written as one selector list, so nobody arriving by keyboard is given less than someone arriving by mouse.
| Item | By eye | By screen reader | Control |
|---|---|---|---|
| 01 Speed dial | plus turns 45° · three rise | aria-expanded toggled |
click · Escape |
| 02 Toolbar FABs | bar leaves downward | per-button aria-label |
scroll |
| 03 Point ripple | circle from the touched pixel | alert button name | pointer · Enter |
| 04 Star draw | line drawn, then filled | aria-pressed · saved notice |
click |
| 05 Theme button | sun and moon swap | aria-pressed · name rewritten |
click |
| 06 Close ring | dashed ring starts turning | close button name | hover · Tab focus |
| 07 Search FAB | circle widens into a field | aria-expanded · field name |
click · Escape |
| 08 Help FAB | panel lifts into place | aria-live reply notice |
click · Enter |
| 09 Drag FAB | snaps to the nearer edge | position read out | long-press drag · ←→ |
Naming a graphic-only control is spelled out in the MDN aria-label reference, and the registered custom property behind 07 is covered on the MDN @property page. Neighboring navigation parts are collected under the nav category, and the ones with real JavaScript behind them live under the JS category.
FAQ
How much contrast does an icon need?
An icon is a graphic, not text, so the bar is 3:1 against its background rather than the 4.5:1 that body text has to clear. Cream on near-black, as in the alert button in 03, sits far above that line, and so does near-black on a white card in 06. The yellow glyphs in 02 are the ones to watch, which is why they ride a dark bar instead of the page.
Where should a floating action button sit?
The bottom right is the default because that is where a right thumb lands, but it is not a law. Left-handed people, tall phones, and a bottom tab bar all break it in different ways. That is the reason 09 exists at all: rather than choosing the spot for the reader, it lets them hold and move the button and then snaps it to the nearer edge. When moving is not on the table, clear the height of the bottom bar at the very least.
Can these be done without JavaScript?
The visual half mostly can. Hover, focus, and a checkbox trick cover the ring in 06 and a large part of 04 and 05, and the width flow in 07 is pure CSS once --w is registered. What cannot be faked is reading coordinates for 03, comparing scroll positions for 02, and measuring a hold against 350ms for 09, so those three ship with the handful of lines you see above.