Almost every button in a design system is finished the moment it has a hover state and a focus ring. That's fine for a link. It falls apart the second the button does something that takes time, something irreversible, or something the user needs confirmation of — because at that point the interesting design work isn't the resting style, it's the two seconds after the click. Did it copy? Is it downloading, or did nothing happen? Am I about to delete this permanently? A button that goes quiet during those two seconds reads as broken, and users click it again.
Below are ten free, self-contained button snippets built around exactly that problem: a copy button that confirms in place, a download button with a real progress ring, a hold-to-confirm gesture that replaces an "are you sure?" dialog, a like button that bursts, a press that behaves like a physical object, a segmented toggle with a glider that measures itself, a three-way theme switch that actually follows the OS, a split button, an expanding FAB, and an add-to-calendar menu that generates a valid .ics file in the browser. Every one is a live, interactive preview — click them right here in the article — and each exports to React, Vue, Angular or Tailwind in one click from its snippet page. All ten are plain HTML, CSS and vanilla JavaScript with no dependencies and no CDN scripts.
What these ten get right
- Every state the button can be in is a named state. Idle, loading, done. Held, released too soon, confirmed. The transitions between them are explicit functions, not a pile of booleans — which is why the awkward cases (clicking twice, releasing early, closing mid-animation) behave instead of stacking up.
- Feedback lands where the user is already looking. The confirmation happens in the button — an icon swap, a filling ring, a colour shift — rather than in a toast in the corner of the screen that half of users never see.
- Pointer Events, not mouse events. One set of handlers covers mouse, touch and pen. The hold-to-confirm button goes further and uses pointer capture, so a thumb that drifts a few pixels off the button doesn't strand it mid-gesture.
- The accessibility is in the markup, not bolted on. The segmented control is a real
role="tablist"witharia-selectedand arrow-key navigation; the FAB togglesaria-expandedand closes on Escape; the split button closes on an outside click. These are the details that separate a component from a demo. - Nothing leaks. Particles remove themselves when their animation finishes, object URLs are revoked after the download fires, timers reset the button to idle. You can hammer any of these for a minute and the DOM looks exactly as it did at the start.
1. Copy Button
The one every developer-facing product needs: an icon button on a code block that swaps to a green checkmark for two seconds when the text lands on the clipboard, plus inline copy buttons on a row of credentials.
How it works: navigator.clipboard.writeText(text) is asynchronous and returns a promise, which matters more than it looks — clipboard access is denied outside a secure context (HTTPS or localhost), in some privacy modes, and when the user has refused the permission, so the .catch() is doing real work rather than satisfying a linter. The feedback is a pure CSS icon swap: the button contains both a copy icon and a checkmark, the checkmark is display: none at rest, and a single .copied class flips which one shows while tinting the button green. No DOM manipulation, no icon library, no re-render. A setTimeout removes the class after two seconds — long enough to register, short enough that the button is ready again before you want it.
Best for: code blocks, API keys, webhook URLs, share links, invite codes — anywhere you currently expect people to select text by hand. Tip: if you need to support non-HTTPS contexts, put the old document.execCommand('copy') path inside the catch: create a temporary textarea, set its value, select it, copy, remove it. It's deprecated, but it's the only thing that works when the Clipboard API is unavailable, and the fallback costs six lines.
Grab the code: Copy Button
2. Download Button
Click and the label gives way to a progress ring with a live percentage, which fills, then resolves into a checkmark and "Saved" before returning to idle on its own.
How it works: three layers — idle, loading, done — are stacked absolutely inside a fixed-size button and cross-faded with opacity and a small vertical slide, so the button never changes width and there's no layout jank to animate around. The ring is the standard stroke-dash technique: two SVG circles, with stroke-dasharray set to the circumference and stroke-dashoffset driven toward zero to draw the arc, and the whole SVG rotated -90deg so it fills from the top. The interesting choice is that the offset is updated inside a requestAnimationFrame loop rather than by a CSS transition — partly because utility frameworks don't reliably transition stroke-dashoffset, and partly because it lets the fill be eased deliberately: an ease-out curve of 1 - (1 - p)² makes the ring race ahead early and slow near the end, which is what a real transfer feels like. A linear fill reads as fake. The percentage text is derived from the same p, so the number and the arc can never disagree, and a re-entrancy guard ignores clicks while the button is already loading or done, so spamming it can't stack animations.
Best for: exports, report generation, file saves, anything where the wait is long enough to notice. Tip: wire the loop to real progress where you can — XMLHttpRequest's progress event or a streamed fetch reader both give you loaded/total. Where you genuinely can't know, keep the eased fake fill but cap it short of 100% until the actual response lands, so the ring never sits full while the user waits.
Grab the code: Download Button
3. Hold to Confirm Button
Press and hold, a bar fills, and the destructive action only fires when it completes. Let go early and it retracts with a "released too soon" note.
How it works: this replaces a modal with a gesture, and the implementation is a good lesson in why time-based beats transition-based. The fill runs in a requestAnimationFrame loop measured against performance.now(), computing progress = elapsed / HOLD_MS every frame, so the bar always reflects exactly how long the button has actually been held — release halfway and it's genuinely halfway, which a fixed CSS animation can't guarantee. Hitting progress >= 1 fires confirm() once and stops the loop, so the action can't double-fire. The touch handling is the part worth stealing: on pointerdown the button calls setPointerCapture, which means it still receives pointerup even if your finger slides off the button — without it, a small drift on a phone drops the release event and leaves the hold stuck running. touch-action: none stops the page scrolling under the gesture, and pointerup, pointercancel and a guarded pointerleave all route to the same cancel(). On success the button locks into a done state, so the next press resets rather than deleting something twice.
Best for: delete, wipe, sign out everywhere, disconnect, transfer ownership — destructive actions where a modal is heavier than the decision deserves. Tip: HOLD_MS is the entire safety dial — the snippet ships at 1200ms. That's already a reasonable default; push much past two seconds and people assume the button is broken and let go, while much under 800ms stops feeling deliberate. Put your real call inside confirm() — it's the single choke point, so integration is one line.
Grab the code: Hold to Confirm Button
4. Like Burst Button
Tap the heart and it pops into red past its own size while a dozen coloured particles spray outward and the count ticks up.
How it works: two mechanisms, both small. The heart is an inline SVG that gains a .liked class, which fills it red and runs a keyframe scaling it from 0 up past 1 to 1.25 and back down on an overshooting cubic-bezier — that brief overshoot is the entire difference between "the heart sprang to life" and "the heart changed colour". The burst is twelve particles created on each like and sent to evenly spaced angles of (i / n) × 2π with a little random jitter and a random distance, positioned with cos and sin. The even angular spacing is what makes it read as a deliberate firework rather than random scatter; the jitter stops it looking mechanical. Each particle animates via the Web Animations API — element.animate() returns a handle whose onfinish removes the particle from the DOM — which is a genuinely nice pattern for short-lived elements: no animationend listeners to attach and detach across a dozen nodes, no cleanup pass, and nothing accumulates however many times you click. Unliking removes the state without a burst, because celebrations belong to the positive action only.
Best for: social feeds, comment threads, reactions, "was this helpful?" widgets. Tip: the count updates optimistically — immediately, before any server round trip — which is right for perceived speed, but it means you own the failure path. If the request fails, roll the count and the class back and say so; an optimistic UI that silently diverges from the database is worse than a slow one.
Grab the code: Like Burst Button
5. Brutalist Press Button
A thick black border, flat saturated fill and a hard offset shadow — and on press the button travels down into the space the shadow was occupying.
How it works: the resting look is one detail — box-shadow: 5px 5px 0 #111 with no blur radius, which is what makes it read as a literal offset silhouette sitting behind the button rather than a soft drop shadow. The press is where it earns its place: on :active the button translates by exactly the shadow's offset, translate(5px, 5px), while the shadow itself collapses to 0 0 0. The two numbers must match precisely — translate further than the shadow and the button appears to sink through the page; translate less and it looks like it's stuck partway. That one constraint is the whole illusion, and it's why this reads as a physical object being pushed flush against the surface when a generic transform: scale(0.98) reads as nothing at all. The high-contrast styling is also an accessibility argument, not just an aesthetic one: a 3px solid border and a flat fill make "this is clickable" unambiguous in a way that low-contrast neumorphic surfaces never managed.
Best for: landing pages, indie products, developer tools, anywhere the design has personality — and anywhere a soft, blurred button has been failing to look pressable. Tip: keep the offset and the translate as a single custom property so they can't drift apart when someone tweaks the shadow later, and check the pressed state on touch — :active fires on tap, so the mechanic works on mobile without extra code.
Grab the code: Brutalist Press Button
6. Segmented Toggle
The iOS-style pill of mutually exclusive options, with a white glider that slides and resizes to fit segments of different widths.
How it works: the glider is a single element behind the segments, and selecting a segment reads that segment's live offsetWidth and offsetLeft and writes them to the glider's width and transform: translateX(). Measuring from the DOM rather than hard-coding sizes is what lets "Grid", "List" and "Compact" — or "Monthly" and "Annual −20%" — all work in the same control without anyone maintaining a table of pixel offsets. It slides with translateX rather than animating left, which keeps the movement on the compositor instead of forcing a layout recalculation every frame; only the width transition relayouts, and only for one tiny element. The whole thing is wired by a reusable setupGroup() called once per control — twice in the demo, proving the pattern scales — and each group is a real role="tablist" of role="tab" buttons with aria-selected and keyboard navigation, so it's operable without a mouse.
Best for: two to four mutually exclusive choices — view modes, billing periods, time ranges, filters. Below five options it beats a dropdown outright, because every choice is visible without a click. Tip: call moveGlider() on resize and after web fonts load. A glider positioned before the font swaps will sit a few pixels off, and it's the kind of bug that only ever reproduces on someone else's connection.
Grab the code: Segmented Toggle
7. Colour Mode Toggle
A three-way Light / Dark / System switch — and System is a live binding, not a one-time snapshot.
How it works: the third option is the whole point. A two-state dark-mode switch ignores that the user's device already has a preference that changes with the time of day, so the modern pattern is Light, Dark and System — where System defers to prefers-color-scheme and keeps deferring. The snippet listens to the matchMedia('(prefers-color-scheme: dark)') change event and re-applies the theme whenever the OS flips, but only while the user is in System mode: pick Light or Dark explicitly and you've overridden the OS; pick System and you hand control back. That live-follow behaviour is what separates a real three-way toggle from one that merely has a third button. The theming itself is CSS custom properties — tokens for background, text, border and accent defined on the container, overridden under a [data-theme="dark"] selector — so switching themes is one attribute change on one element rather than a class toggle per component, with a transition on the colour properties to make it a crossfade instead of a flash. The thumb slides with translateX() of the active index.
Best for: any site with a dark theme, which by now is most of them. Tip: the ugliest bug in this pattern is the flash of the wrong theme on first paint, and no toggle can fix it from inside the page body. Set data-theme from a tiny blocking inline script in the <head> that reads the stored choice before the stylesheet renders — then let this component handle everything after that.
Grab the code: Colour Mode Toggle
8. Split Button
A primary action on the left, a chevron on the right that opens a menu of alternatives — the deploy-button pattern from every dashboard you've used.
How it works: the layout is inline-flex on a position: relative container, with the main button rounded on its left corners only and the arrow button rounded on its right, so they read as one control with a seam. Notably there's no wrapper with overflow: hidden — that would clip the dropdown panel, which is the most common way this component gets broken. The seam itself is a border-left: 1px solid rgba(255,255,255,0.25) on the arrow half: because both halves share a background colour, a fully opaque divider looks harsh, and dropping the alpha to 25% reads as an inset separator rather than a line drawn on top. The menu animates in with a 150ms keyframe from opacity: 0 and translateY(-6px), so it emerges from the button rather than appearing from nowhere. Two behaviours make it usable: opening any dropdown first sweeps all others closed, so multiple split buttons on a page can't stack open panels, and a document-level click handler uses e.target.closest('.split-btn') to close on any click outside.
Best for: a clear default action with occasional variants — Deploy / Deploy to staging, Save / Save as, Send / Schedule. Tip: the outside-click handler is the reusable part. closest() is the cleanest test for "did this click land inside my component?", and it's the same three lines behind every dropdown, popover and menu you'll write after this one.
Grab the code: Split Button
9. Expanding FAB
A floating action button whose + rotates into a × while three labelled sub-actions stagger upward over a blurred backdrop.
How it works: three small tricks, none of which need JavaScript to run the animation. The stagger is a CSS custom property: each sub-action carries --i (1, 2, 3) and its transition-delay is calc((var(--i) - 1) × 0.05s), so they enter 50ms apart — and because transition-delay applies to the exit too, the close animation reverses naturally, topmost item first, with no separate close sequence to write. The icon needs no swap at all: the + character rotated 45° is a ×, so transform: rotate(45deg) on a spring curve does the whole thing. The tooltips have no hover logic either — their opacity is driven by the parent item's open state, so they simply exist when the actions do. The backdrop is a fixed layer that fades to rgba(0,0,0,0.2) with backdrop-filter: blur(2px) and toggles pointer-events so it can't swallow clicks while closed. aria-expanded tracks the state and Escape closes it.
Best for: mobile-first interfaces where the primary action needs to follow the user down a long scroll, and two or three related actions belong with it. Tip: keep it to three or four sub-actions. Past that the stagger becomes a queue people wait through, and what you actually want is a sheet or a menu.
Grab the code: Expanding FAB
10. Add to Calendar Button
A dropdown that hands the same event to Google, Outlook, Office 365 and Yahoo as pre-filled links — and to Apple Calendar and everything else as a downloadable .ics file generated in the browser.
How it works: all the event details live in one EVENT object — title, description, location, start, end — and every menu item builds its own output from it, so changing the event is a one-place edit. The fiddly part, done for you, is that each provider wants a different URL shape: Google and Yahoo take compact UTC timestamps in their own parameter names, while Outlook and Office 365 take ISO datetimes in startdt/enddt. Apple has no add-event URL scheme at all, which is why the .ics path matters: downloadIcs() assembles a minimal valid VCALENDAR/VEVENT block, wraps it in a Blob, and triggers a download through a temporary object URL that's revoked immediately afterwards so it doesn't leak. Time zones are the other classic failure, and they're handled in one helper — toUTC() converts your local ISO start and end into the YYYYMMDDTHHMMSSZ format calendars require, so you author the event in plain local time and never think about it again.
Best for: webinars, event pages, booking confirmations, course schedules, anything where "they forgot" is the main failure mode. Tip: this is the snippet on the list most worth reading rather than just pasting — the .ics generation and the UTC helper are useful wherever you need to hand a user a file your server never touched. The same Blob-plus-object-URL pattern exports CSVs, JSON backups and generated text files entirely client-side.
Grab the code: Add to Calendar Button
How to drop these into your project
- Open any snippet and hit View / Edit Code to see the HTML, CSS and JS in separate tabs, then paste the HTML into your markup, the CSS into your stylesheet, and the JS before your closing
</body>tag — or use the one-click export to React, Vue, Angular or Tailwind right from the snippet page. All ten are dependency-free, so there's nothing else to install. - Keep the state machine, restyle everything else. The reusable parts here are the idle → loading → done layering, the
confirm()/cancel()/reset()split, the re-entrancy guard, and the timeout that returns a button to idle. Those survive any redesign; the colours don't. - Use a real
<button>. These all do, and it's why they're keyboard-operable and announced correctly for free. If you swap in adivto dodge some inherited CSS you inherit the whole accessibility bill instead: focusability, Enter and Space handling, and a role. - Decide the failure path before you ship the happy one. Clipboard writes reject outside a secure context, downloads fail, likes hit a 500. Each of these buttons has an obvious place to put that handling — the
.catch(), the completion callback — and a confirmation state that lies is worse than no confirmation at all. - Respect
prefers-reduced-motion. The particle burst, the spring rotations and the staggered FAB entrance are exactly what the setting exists for. A single@media (prefers-reduced-motion: reduce)block that drops the transforms to near-zero duration keeps every state change legible while removing the motion — the feedback survives, the animation doesn't. - In React, mind the timers. The copy button's two-second reset, the download button's auto-return to idle and the hold button's animation frame all need clearing on unmount, or a state update fires against a component that's already gone. Store the handle in a ref and clear it in the effect's cleanup.
Final thought
The gap between a button that works and a button that feels finished is almost never the resting style — it's whether the interface keeps talking after the click. A checkmark for two seconds, a ring that fills honestly, a bar that only completes if you really meant it, a heart that pops. None of these are more than a few dozen lines, and all of them replace the same thing: a user clicking again because nothing appeared to happen.
Click them, hold them, retune the timings, and export to your framework of choice in one click. Browse the full collection at FWD Tools UI Snippets — it's free and runs entirely in your browser.
