Open demo

CSS code

HTML
<button type="button" class="morph morph-cart" aria-pressed="false">
  <span class="morph-off"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h15l-1.6 9H8L6 3H3"/><circle cx="9" cy="20" r="1.4"/><circle cx="18" cy="20" r="1.4"/></svg>Add to cart</span>
  <span class="morph-on" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>Added</span>
</button>

<button type="button" class="morph morph-follow" aria-pressed="false">
  <span class="morph-off"><span aria-hidden="true">+</span>Follow</span>
  <span class="morph-on" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>Following</span>
</button>

<button type="button" class="morph morph-copy" data-copy="npm install @example/ui">
  <span class="morph-off"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/></svg>Copy code</span>
  <span class="morph-on" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>Copied</span>
</button>
<p class="sr-only" role="status" id="morph-status"></p>
CSS
/* Buttons that confirm: each <button> holds a resting label and a done label. aria-pressed (or data-done on the
   copy button) swaps which one is shown. The resting label stays in the accessible name, so the name never changes. */
.morph{position:relative;display:inline-flex;align-items:center;justify-content:center;min-width:172px;border:0;border-radius:11px;
  padding:13px 26px;font:inherit;font-size:.9rem;font-weight:700;line-height:normal;cursor:pointer;
  transition:background-color .25s,color .25s,box-shadow .25s}
.morph span{display:inline-flex;align-items:center;gap:8px}
.morph svg{display:block;width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.4;flex-shrink:0}
.morph .morph-on{display:none}
.morph[aria-pressed=true] .morph-on,.morph[data-done] .morph-on{display:inline-flex}
.morph[aria-pressed=true] .morph-off,.morph[data-done] .morph-off{position:absolute;width:1px;height:1px;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
.morph:focus-visible{outline:2px solid #4f46e5;outline-offset:3px}
.morph-cart{background:#4f46e5;color:#fff}
.morph-cart:hover{background:#4338ca}
.morph-cart[aria-pressed=true]{background:#15803d}
.morph-follow{background:#101828;color:#fff}
.morph-follow:hover{background:#344054}
.morph-follow[aria-pressed=true]{background:#fff;color:#344054;box-shadow:inset 0 0 0 1.5px #838b9b}
.morph-copy{background:#eef0f4;color:#344054}
.morph-copy:hover{background:#e4e7ec}
.morph-copy[data-done]{background:#dcfce7;color:#15803d}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
@media (prefers-reduced-motion:reduce){.morph{transition:none}}
JavaScript
// Toggle buttons flip aria-pressed; the copy button writes its data-copy text to the clipboard,
// shows "Copied" for two seconds and announces the result through the status region.
document.querySelectorAll('.morph[aria-pressed]').forEach(btn => {
  btn.addEventListener('click', () => {
    btn.setAttribute('aria-pressed', String(btn.getAttribute('aria-pressed') !== 'true'));
  });
});
const morphStatus = document.getElementById('morph-status');
document.querySelectorAll('.morph[data-copy]').forEach(btn => {
  let timer;
  btn.addEventListener('click', async () => {
    let message = 'Copied to clipboard';
    try { await navigator.clipboard.writeText(btn.dataset.copy); btn.dataset.done = ''; }
    catch { message = 'Copy failed. Select the text and copy it manually.'; }
    if (morphStatus) { morphStatus.textContent = ''; setTimeout(() => { morphStatus.textContent = message; }, 50); }
    clearTimeout(timer);
    timer = setTimeout(() => { delete btn.dataset.done; if (morphStatus) morphStatus.textContent = ''; }, 2000);
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<button type="button" class="btn morph morph-cart" aria-pressed="false">
  <span class="morph-off"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h15l-1.6 9H8L6 3H3"/><circle cx="9" cy="20" r="1.4"/><circle cx="18" cy="20" r="1.4"/></svg>Add to cart</span>
  <span class="morph-on" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>Added</span>
</button>

<button type="button" class="btn morph morph-follow" aria-pressed="false">
  <span class="morph-off"><span aria-hidden="true">+</span>Follow</span>
  <span class="morph-on" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>Following</span>
</button>

<button type="button" class="btn morph morph-copy" data-copy="npm install @example/ui">
  <span class="morph-off"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/></svg>Copy code</span>
  <span class="morph-on" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>Copied</span>
</button>
<p class="visually-hidden" role="status" id="morph-status"></p>
CSS
/* Buttons that confirm: .btn restyled through --bs-btn-* variables. aria-pressed (or data-done on the copy
   button) swaps the visible label; the resting label stays in the accessible name as .visually-hidden text. */
.morph{--bs-btn-padding-x:26px;--bs-btn-padding-y:13px;--bs-btn-font-size:.9rem;--bs-btn-font-weight:700;
  --bs-btn-line-height:normal;--bs-btn-border-width:0;--bs-btn-border-radius:11px;
  --bs-btn-focus-box-shadow:0 0 0 3px #f4f5f9,0 0 0 5px #4f46e5;
  position:relative;display:inline-flex;align-items:center;justify-content:center;min-width:172px;
  transition:background-color .25s,color .25s,box-shadow .25s}
.morph span{display:inline-flex;align-items:center;gap:8px}
.morph svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.4;flex-shrink:0}
.morph .morph-on{display:none}
.morph[aria-pressed=true] .morph-on,.morph[data-done] .morph-on{display:inline-flex}
.morph[aria-pressed=true] .morph-off,.morph[data-done] .morph-off{position:absolute!important;width:1px!important;
  height:1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important}
.morph-cart{--bs-btn-color:#fff;--bs-btn-bg:#4f46e5;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#4338ca;
  --bs-btn-active-color:#fff;--bs-btn-active-bg:#4338ca}
.morph-cart[aria-pressed=true]{--bs-btn-bg:#15803d;--bs-btn-hover-bg:#15803d;--bs-btn-active-bg:#15803d}
.morph-follow{--bs-btn-color:#fff;--bs-btn-bg:#101828;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#344054;
  --bs-btn-active-color:#fff;--bs-btn-active-bg:#344054}
.morph-follow[aria-pressed=true]{--bs-btn-color:#344054;--bs-btn-bg:#fff;--bs-btn-hover-color:#344054;
  --bs-btn-hover-bg:#fff;--bs-btn-active-color:#344054;--bs-btn-active-bg:#fff;box-shadow:inset 0 0 0 1.5px #838b9b}
.morph-follow[aria-pressed=true]:focus-visible{box-shadow:inset 0 0 0 1.5px #838b9b,var(--bs-btn-focus-box-shadow)}
.morph-copy{--bs-btn-color:#344054;--bs-btn-bg:#eef0f4;--bs-btn-hover-color:#344054;--bs-btn-hover-bg:#e4e7ec;
  --bs-btn-active-color:#344054;--bs-btn-active-bg:#e4e7ec}
.morph-copy[data-done]{--bs-btn-color:#15803d;--bs-btn-bg:#dcfce7;--bs-btn-hover-color:#15803d;
  --bs-btn-hover-bg:#dcfce7;--bs-btn-active-color:#15803d;--bs-btn-active-bg:#dcfce7}
@media (prefers-reduced-motion:reduce){.morph{transition:none}}
JavaScript
// Toggle buttons flip aria-pressed; the copy button writes its data-copy text to the clipboard,
// shows "Copied" for two seconds and announces the result through the status region.
document.querySelectorAll('.morph[aria-pressed]').forEach(btn => {
  btn.addEventListener('click', () => {
    btn.setAttribute('aria-pressed', String(btn.getAttribute('aria-pressed') !== 'true'));
  });
});
const morphStatus = document.getElementById('morph-status');
document.querySelectorAll('.morph[data-copy]').forEach(btn => {
  let timer;
  btn.addEventListener('click', async () => {
    let message = 'Copied to clipboard';
    try { await navigator.clipboard.writeText(btn.dataset.copy); btn.dataset.done = ''; }
    catch { message = 'Copy failed. Select the text and copy it manually.'; }
    if (morphStatus) { morphStatus.textContent = ''; setTimeout(() => { morphStatus.textContent = message; }, 50); }
    clearTimeout(timer);
    timer = setTimeout(() => { delete btn.dataset.done; if (morphStatus) morphStatus.textContent = ''; }, 2000);
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<button type="button" aria-pressed="false" class="morph group relative inline-flex min-w-[172px] cursor-pointer items-center justify-center rounded-[11px] px-[26px] py-[13px] text-[.9rem] font-bold transition-[background-color,color,box-shadow] duration-[250ms] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#4f46e5] motion-reduce:transition-none bg-[#4f46e5] text-white hover:bg-[#4338ca] aria-pressed:bg-[#15803d] aria-pressed:hover:bg-[#15803d]">
  <span class="inline-flex items-center gap-2 group-aria-pressed:sr-only"><svg viewBox="0 0 24 24" aria-hidden="true" class="block size-4 shrink-0 fill-none stroke-current stroke-[2.4]"><path d="M6 6h15l-1.6 9H8L6 3H3"/><circle cx="9" cy="20" r="1.4"/><circle cx="18" cy="20" r="1.4"/></svg>Add to cart</span>
  <span class="hidden items-center gap-2 group-aria-pressed:inline-flex" aria-hidden="true"><svg viewBox="0 0 24 24" class="block size-4 shrink-0 fill-none stroke-current stroke-[2.4]"><path d="M5 13l4 4L19 7"/></svg>Added</span>
</button>

<button type="button" aria-pressed="false" class="morph group relative inline-flex min-w-[172px] cursor-pointer items-center justify-center rounded-[11px] px-[26px] py-[13px] text-[.9rem] font-bold transition-[background-color,color,box-shadow] duration-[250ms] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#4f46e5] motion-reduce:transition-none bg-[#101828] text-white hover:bg-[#344054] aria-pressed:bg-white aria-pressed:text-[#344054] aria-pressed:shadow-[inset_0_0_0_1.5px_#838b9b] aria-pressed:hover:bg-white">
  <span class="inline-flex items-center gap-2 group-aria-pressed:sr-only"><span aria-hidden="true">+</span>Follow</span>
  <span class="hidden items-center gap-2 group-aria-pressed:inline-flex" aria-hidden="true"><svg viewBox="0 0 24 24" class="block size-4 shrink-0 fill-none stroke-current stroke-[2.4]"><path d="M5 13l4 4L19 7"/></svg>Following</span>
</button>

<button type="button" data-copy="npm install @example/ui" class="morph group relative inline-flex min-w-[172px] cursor-pointer items-center justify-center rounded-[11px] px-[26px] py-[13px] text-[.9rem] font-bold transition-[background-color,color,box-shadow] duration-[250ms] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#4f46e5] motion-reduce:transition-none bg-[#eef0f4] text-[#344054] hover:bg-[#e4e7ec] data-done:bg-[#dcfce7] data-done:text-[#15803d] data-done:hover:bg-[#dcfce7]">
  <span class="inline-flex items-center gap-2 group-data-done:sr-only"><svg viewBox="0 0 24 24" aria-hidden="true" class="block size-4 shrink-0 fill-none stroke-current stroke-[2.4]"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/></svg>Copy code</span>
  <span class="hidden items-center gap-2 group-data-done:inline-flex" aria-hidden="true"><svg viewBox="0 0 24 24" class="block size-4 shrink-0 fill-none stroke-current stroke-[2.4]"><path d="M5 13l4 4L19 7"/></svg>Copied</span>
</button>
<p class="sr-only" role="status" id="morph-status"></p>
JavaScript
// Toggle buttons flip aria-pressed; the copy button writes its data-copy text to the clipboard,
// shows "Copied" for two seconds and announces the result through the status region.
document.querySelectorAll('.morph[aria-pressed]').forEach(btn => {
  btn.addEventListener('click', () => {
    btn.setAttribute('aria-pressed', String(btn.getAttribute('aria-pressed') !== 'true'));
  });
});
const morphStatus = document.getElementById('morph-status');
document.querySelectorAll('.morph[data-copy]').forEach(btn => {
  let timer;
  btn.addEventListener('click', async () => {
    let message = 'Copied to clipboard';
    try { await navigator.clipboard.writeText(btn.dataset.copy); btn.dataset.done = ''; }
    catch { message = 'Copy failed. Select the text and copy it manually.'; }
    if (morphStatus) { morphStatus.textContent = ''; setTimeout(() => { morphStatus.textContent = message; }, 50); }
    clearTimeout(timer);
    timer = setTimeout(() => { delete btn.dataset.done; if (morphStatus) morphStatus.textContent = ''; }, 2000);
  });
});

About this set

Three buttons that confirm what they did by changing their label and colour. Add to cart turns green and reads Added, Follow turns into an outlined Following, and Copy code turns pale green and reads Copied for two seconds. Use the first two for on and off choices a shopper or reader can undo, and the copy button beside code samples, coupon codes or share links. Add to cart and Follow are toggle buttons: a short script flips aria-pressed on click, and the CSS swaps the visible label from that attribute. The resting label is not removed but visually hidden, so the accessible name stays Add to cart or Follow and screen readers announce it as pressed or not pressed. The copy button writes the text in its data-copy attribute to the clipboard with the Clipboard API, sets data-done for two seconds and announces Copied to clipboard, or a failure message, through a visually hidden status region. Each button keeps a 172 pixel minimum width so the label swap does not move the layout. The Bootstrap version restyles .btn through the –bs-btn-* variables, and the Tailwind version uses group-aria-pressed and group-data-done variants.

What’s included

  • Add to cart, Follow and Copy code buttons
  • Toggle state kept in aria-pressed, styled from the attribute
  • Accessible name stays the same when the visible label changes
  • Copy result announced through a role="status" region
  • Fixed minimum width so the label swap does not shift the layout

Accessibility

All three are native buttons. The cart and follow buttons carry aria-pressed, and their resting label stays in the accessible name as visually hidden text while the confirmation label is aria-hidden, so the name does not change with the state. The copy button reports success or failure through a visually hidden role="status" paragraph. Keyboard focus draws a 2px indigo outline or ring, and colour transitions are removed under prefers-reduced-motion. The confirmed green was darkened to #15803d so the white label reaches 4.5:1.

Customise it

Change the colours in .morph-cart, .morph-follow and .morph-copy and their [aria-pressed=true] or [data-done] rules, and the text to copy in the data-copy attribute. In Bootstrap edit the --bs-btn-bg and --bs-btn-color variables on the same classes; in Tailwind edit the bg-, aria-pressed: and data-done: utilities.