34 CSS Floating Buttons33 / 34

Light JSMIT licensed

Quick Emoji Reply Reaction Floating Button CSS

The reaction bar: six quick replies that pop out of a floating button, magnify under the pointer like a desktop dock, and burst into particles when chosen. Every reaction is a labelled toggle with aria-pressed, the counts update live, and the whole strip is keyboard-navigable with arrow keys — because reactions are content, not decoration.

Published Updated

Live Demo
Try it

The code

<section class="fb-33" aria-label="Quick emoji reaction floating button demo">
  <div class="fb-33__stage">
    <p class="fb-33__eyebrow">:has() neighbour magnify · trig burst</p>
    <h2 class="fb-33__title">React without leaving the thread</h2>
    <p class="fb-33__sub">Open the strip, then sweep across it. The hovered reaction grows most and its neighbours grow less — that ramp is what makes it feel like a dock.</p>
    <article class="fb-33__msg">
      <span class="fb-33__av" aria-hidden="true"></span>
      <div class="fb-33__bub">
        <b>Marcus Tan</b>
        <p>Shipped the FAB set — 34 patterns, all keyboard-tested. Docs are in the same file.</p>
        <ul class="fb-33__tally" id="fb-33-tally"></ul>
      </div>
    </article>
    <div class="fb-33__dock" id="fb-33-dock" data-open="false">
      <ul class="fb-33__strip" id="fb-33-strip">
        <li style="--fb-33-i:0"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="👍" data-fb-n="Thumbs up"><span aria-hidden="true">👍</span></button></li>
        <li style="--fb-33-i:1"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="🎉" data-fb-n="Celebrate"><span aria-hidden="true">🎉</span></button></li>
        <li style="--fb-33-i:2"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="🔥" data-fb-n="Fire"><span aria-hidden="true">🔥</span></button></li>
        <li style="--fb-33-i:3"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="❤️" data-fb-n="Love"><span aria-hidden="true">❤️</span></button></li>
        <li style="--fb-33-i:4"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="😮" data-fb-n="Wow"><span aria-hidden="true">😮</span></button></li>
        <li style="--fb-33-i:5"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="🙌" data-fb-n="Applause"><span aria-hidden="true">🙌</span></button></li>
      </ul>
      <button class="fb-33__fab" id="fb-33-fab" type="button" aria-expanded="false" aria-controls="fb-33-strip" aria-label="Add a reaction">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.4" fill="none" stroke="currentColor" stroke-width="1.9"/><circle cx="9.3" cy="10.2" r="1.2" fill="currentColor"/><circle cx="14.7" cy="10.2" r="1.2" fill="currentColor"/><path d="M8.6 14.2c1.9 1.9 4.9 1.9 6.8 0" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
      </button>
    </div>
    <p class="fb-33__live" id="fb-33-live" role="status" aria-live="polite"></p>
    <p class="fb-33__chip">arrow keys wrap · 8 particles · aria-pressed toggles</p>
  </div>
</section>
.fb-33 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-33-bg);
  --fb-33-bg: linear-gradient(180deg,oklch(0.97 0.01 250),oklch(0.93 0.02 262));
  --fb-33-brand: oklch(0.5 0.16 272);
  --fb-33-ink: oklch(0.22 0.025 265);
  --fb-33-mut: oklch(0.53 0.015 265);
  --fb-33-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-33-ink);
  display: grid;
  place-items: center;
  padding: clamp(20px,5vw,56px);
}

.fb-33 *,
.fb-33 *::before,
.fb-33 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.fb-33__stage {
  width: min(100%,56ch);
  display: grid;
  gap: clamp(16px,3vw,26px);
  justify-items: center;
  text-align: center;
}

.fb-33__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .15em;
  text-transform: uppercase;
  color: var(--fb-33-brand);
}

.fb-33__title {
  font-size: clamp(25px,4.4vw,42px);
  line-height: 1.05;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-33__sub {
  max-width: 50ch;
  font-size: clamp(13.5px,1.7vw,16.5px);
  line-height: 1.62;
  color: var(--fb-33-mut);
  text-wrap: pretty;
}

.fb-33__msg {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  width: min(100%,440px);
  text-align: left;
  padding: 14px;
  border-radius: 20px;
  background: var(--fb-33-card);
  border: 1px solid oklch(0.22 0.025 265/.08);
  box-shadow: 0 18px 44px -30px oklch(0.22 0.025 265/.9);
}

.fb-33__av {
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: linear-gradient(140deg,oklch(0.78 0.12 250),oklch(0.55 0.16 300)),url("https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?q=80&w=160&auto=format&fit=crop") center/cover;
}

.fb-33__bub {
  display: grid;
  gap: 5px;
  min-width: 0;
}

.fb-33__bub b {
  font-size: 13.5px;
  font-weight: 650;
}

.fb-33__bub p {
  font-size: 13.5px;
  line-height: 1.55;
  color: oklch(0.35 0.02 265);
  text-wrap: pretty;
}

.fb-33__tally {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 4px;
}

.fb-33__tally li {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 12px;
  font-weight: 620;
  padding: 4px 9px;
  border-radius: 99px;
  background: oklch(0.5 0.16 272/.1);
  border: 1px solid oklch(0.5 0.16 272/.28);
  color: var(--fb-33-brand);
  font-variant-numeric: tabular-nums;
  animation: fb-33-pop .3s cubic-bezier(.2,1.6,.4,1);
}

@keyframes fb-33-pop {
  from {
    scale: .5;
    opacity: 0;
  }
}

.fb-33__dock {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 14px;
  padding-top: 10px;
}

.fb-33__strip {
  list-style: none;
  display: flex;
  align-items: flex-end;
  gap: 6px;
  padding: 7px 10px;
  border-radius: 99px;
  background: var(--fb-33-card);
  border: 1px solid oklch(0.22 0.025 265/.08);
  box-shadow: 0 16px 40px -22px oklch(0.22 0.025 265/.9);
  opacity: 0;
  translate: 0 10px;
  pointer-events: none;
  transition: opacity .24s ease,translate .3s cubic-bezier(.2,1.1,.3,1);
}

.fb-33__dock[data-open="true"] .fb-33__strip {
  opacity: 1;
  translate: 0 0;
  pointer-events: auto;
}

.fb-33__react {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  font-size: 24px;
  line-height: 1;
  scale: 0;
  translate: 0 12px;
  transform-origin: 50% 100%;
  transition: scale .34s cubic-bezier(.2,1.6,.4,1),translate .3s cubic-bezier(.2,1.4,.4,1),background .18s ease;
  transition-delay: calc(var(--fb-33-i) * 42ms);
}

.fb-33__dock[data-open="true"] .fb-33__react {
  scale: 1;
  translate: 0 0;
}

.fb-33__react span {
  display: block;
  filter: drop-shadow(0 2px 4px oklch(0.22 0.025 265/.2));
}

.fb-33__dock[data-open="true"] .fb-33__react:hover {
  scale: 1.5;
  translate: 0 -10px;
  transition-delay: 0s;
}

.fb-33__dock[data-open="true"] li:hover + li .fb-33__react,
.fb-33__dock[data-open="true"] li:has(+ li:hover) .fb-33__react {
  scale: 1.24;
  translate: 0 -5px;
  transition-delay: 0s;
}

.fb-33__react:focus-visible {
  outline: 3px solid var(--fb-33-brand);
  outline-offset: 2px;
  scale: 1.35;
}

.fb-33__react[aria-pressed="true"] {
  background: oklch(0.5 0.16 272/.14);
}

.fb-33__burst {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.fb-33__burst i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--fb-33-brand);
  animation: fb-33-fly .62s cubic-bezier(.2,.7,.3,1) forwards;
}

@keyframes fb-33-fly {
  to {
    translate: calc(cos(var(--fb-33-a)) * 42px) calc(sin(var(--fb-33-a)) * -42px);
    opacity: 0;
    scale: .3;
  }
}

.fb-33__fab {
  display: grid;
  place-items: center;
  inline-size: 58px;
  block-size: 58px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  color: oklch(0.99 0.01 272);
  background: linear-gradient(150deg,oklch(0.6 0.17 276),var(--fb-33-brand));
  box-shadow: 0 12px 30px -12px oklch(0.5 0.16 272/.85),0 2px 6px oklch(0.3 0.07 272/.28);
  transition: transform .28s cubic-bezier(.2,1.3,.4,1),rotate .3s ease;
}

.fb-33__fab svg {
  width: 26px;
  height: 26px;
}

.fb-33__fab:hover {
  transform: translateY(-2px) scale(1.04);
}

.fb-33__fab:active {
  transform: scale(.94);
}

.fb-33__fab:focus-visible {
  outline: 3px solid var(--fb-33-brand);
  outline-offset: 4px;
}

.fb-33__dock[data-open="true"] .fb-33__fab {
  rotate: 22deg;
}

.fb-33__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--fb-33-brand);
  min-height: 18px;
}

.fb-33__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--fb-33-mut);
  padding: 8px 14px;
  border: 1px solid oklch(0.22 0.025 265/.14);
  border-radius: 99px;
}

@media (prefers-reduced-motion: reduce) {
  .fb-33 * {
    transition-duration: .01ms !important;
    animation: none !important;
  }
}
(() => {
  const dock = document.getElementById('fb-33-dock');
  const fab = document.getElementById('fb-33-fab');
  const tally = document.getElementById('fb-33-tally');
  const live = document.getElementById('fb-33-live');
  if (!dock || !fab || fab.dataset.fbWired) return;
  fab.dataset.fbWired = '1';
  const items = [...dock.querySelectorAll('.fb-33__react')];
  const counts = new Map();
  const set = (open) => {
    dock.dataset.open = String(open);
    fab.setAttribute('aria-expanded', String(open));
    items.forEach((i) => { i.tabIndex = open ? 0 : -1; });
    if (open) setTimeout(() => items[0].focus(), 90);
  };
  const render = () => {
    tally.innerHTML = '';
    counts.forEach((n, e) => {
      if (!n) return;
      const li = document.createElement('li');
      li.textContent = e + ' ' + n;
      tally.append(li);
    });
  };
  const burst = (btn) => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const wrap = document.createElement('span');
    wrap.className = 'fb-33__burst';
    for (let i = 0; i < 8; i += 1) {
      const p = document.createElement('i');
      p.style.setProperty('--fb-33-a', (i * 45) + 'deg');
      wrap.append(p);
    }
    btn.append(wrap);
    setTimeout(() => wrap.remove(), 700);
  };
  items.forEach((btn, idx) => {
    btn.setAttribute('aria-label', btn.dataset.fbN + ', 0 reactions');
    btn.addEventListener('click', () => {
      const on = btn.getAttribute('aria-pressed') === 'true';
      const e = btn.dataset.fbE;
      const n = (counts.get(e) || 0) + (on ? -1 : 1);
      counts.set(e, Math.max(0, n));
      btn.setAttribute('aria-pressed', String(!on));
      btn.setAttribute('aria-label', btn.dataset.fbN + ', ' + counts.get(e) + ' reactions');
      if (!on) burst(btn);
      render();
      live.textContent = (on ? 'Removed ' : 'Added ') + btn.dataset.fbN;
    });
    btn.addEventListener('keydown', (ev) => {
      if (ev.key === 'ArrowRight') { ev.preventDefault(); items[(idx + 1) % items.length].focus(); }
      if (ev.key === 'ArrowLeft') { ev.preventDefault(); items[(idx - 1 + items.length) % items.length].focus(); }
      if (ev.key === 'Escape') { set(false); fab.focus(); }
    });
  });
  fab.addEventListener('click', () => set(dock.dataset.open !== 'true'));
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Floating Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Quick Emoji Reply Reaction Floating Button CSS
Source: https://codefronts.com/components/css-floating-buttons/fb15/

The reaction bar: six quick replies that pop out of a floating button, magnify under the pointer like a desktop dock, and burst into particles when chosen. Every reaction is a labelled toggle with aria-pressed, the counts update live, and the whole strip is keyboard-navigable with arrow keys — because reactions are content, not decoration.
## HTML
```html
<section class="fb-33" aria-label="Quick emoji reaction floating button demo">
  <div class="fb-33__stage">
    <p class="fb-33__eyebrow">:has() neighbour magnify · trig burst</p>
    <h2 class="fb-33__title">React without leaving the thread</h2>
    <p class="fb-33__sub">Open the strip, then sweep across it. The hovered reaction grows most and its neighbours grow less — that ramp is what makes it feel like a dock.</p>
    <article class="fb-33__msg">
      <span class="fb-33__av" aria-hidden="true"></span>
      <div class="fb-33__bub">
        <b>Marcus Tan</b>
        <p>Shipped the FAB set — 34 patterns, all keyboard-tested. Docs are in the same file.</p>
        <ul class="fb-33__tally" id="fb-33-tally"></ul>
      </div>
    </article>
    <div class="fb-33__dock" id="fb-33-dock" data-open="false">
      <ul class="fb-33__strip" id="fb-33-strip">
        <li style="--fb-33-i:0"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="👍" data-fb-n="Thumbs up"><span aria-hidden="true">👍</span></button></li>
        <li style="--fb-33-i:1"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="🎉" data-fb-n="Celebrate"><span aria-hidden="true">🎉</span></button></li>
        <li style="--fb-33-i:2"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="🔥" data-fb-n="Fire"><span aria-hidden="true">🔥</span></button></li>
        <li style="--fb-33-i:3"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="❤️" data-fb-n="Love"><span aria-hidden="true">❤️</span></button></li>
        <li style="--fb-33-i:4"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="😮" data-fb-n="Wow"><span aria-hidden="true">😮</span></button></li>
        <li style="--fb-33-i:5"><button class="fb-33__react" type="button" tabindex="-1" aria-pressed="false" data-fb-e="🙌" data-fb-n="Applause"><span aria-hidden="true">🙌</span></button></li>
      </ul>
      <button class="fb-33__fab" id="fb-33-fab" type="button" aria-expanded="false" aria-controls="fb-33-strip" aria-label="Add a reaction">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.4" fill="none" stroke="currentColor" stroke-width="1.9"/><circle cx="9.3" cy="10.2" r="1.2" fill="currentColor"/><circle cx="14.7" cy="10.2" r="1.2" fill="currentColor"/><path d="M8.6 14.2c1.9 1.9 4.9 1.9 6.8 0" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
      </button>
    </div>
    <p class="fb-33__live" id="fb-33-live" role="status" aria-live="polite"></p>
    <p class="fb-33__chip">arrow keys wrap · 8 particles · aria-pressed toggles</p>
  </div>
</section>
```
## CSS
```css
.fb-33 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-33-bg);
  --fb-33-bg: linear-gradient(180deg,oklch(0.97 0.01 250),oklch(0.93 0.02 262));
  --fb-33-brand: oklch(0.5 0.16 272);
  --fb-33-ink: oklch(0.22 0.025 265);
  --fb-33-mut: oklch(0.53 0.015 265);
  --fb-33-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-33-ink);
  display: grid;
  place-items: center;
  padding: clamp(20px,5vw,56px);
}

.fb-33 *,
.fb-33 *::before,
.fb-33 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.fb-33__stage {
  width: min(100%,56ch);
  display: grid;
  gap: clamp(16px,3vw,26px);
  justify-items: center;
  text-align: center;
}

.fb-33__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .15em;
  text-transform: uppercase;
  color: var(--fb-33-brand);
}

.fb-33__title {
  font-size: clamp(25px,4.4vw,42px);
  line-height: 1.05;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-33__sub {
  max-width: 50ch;
  font-size: clamp(13.5px,1.7vw,16.5px);
  line-height: 1.62;
  color: var(--fb-33-mut);
  text-wrap: pretty;
}

.fb-33__msg {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  width: min(100%,440px);
  text-align: left;
  padding: 14px;
  border-radius: 20px;
  background: var(--fb-33-card);
  border: 1px solid oklch(0.22 0.025 265/.08);
  box-shadow: 0 18px 44px -30px oklch(0.22 0.025 265/.9);
}

.fb-33__av {
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: linear-gradient(140deg,oklch(0.78 0.12 250),oklch(0.55 0.16 300)),url("https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?q=80&w=160&auto=format&fit=crop") center/cover;
}

.fb-33__bub {
  display: grid;
  gap: 5px;
  min-width: 0;
}

.fb-33__bub b {
  font-size: 13.5px;
  font-weight: 650;
}

.fb-33__bub p {
  font-size: 13.5px;
  line-height: 1.55;
  color: oklch(0.35 0.02 265);
  text-wrap: pretty;
}

.fb-33__tally {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 4px;
}

.fb-33__tally li {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 12px;
  font-weight: 620;
  padding: 4px 9px;
  border-radius: 99px;
  background: oklch(0.5 0.16 272/.1);
  border: 1px solid oklch(0.5 0.16 272/.28);
  color: var(--fb-33-brand);
  font-variant-numeric: tabular-nums;
  animation: fb-33-pop .3s cubic-bezier(.2,1.6,.4,1);
}

@keyframes fb-33-pop {
  from {
    scale: .5;
    opacity: 0;
  }
}

.fb-33__dock {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 14px;
  padding-top: 10px;
}

.fb-33__strip {
  list-style: none;
  display: flex;
  align-items: flex-end;
  gap: 6px;
  padding: 7px 10px;
  border-radius: 99px;
  background: var(--fb-33-card);
  border: 1px solid oklch(0.22 0.025 265/.08);
  box-shadow: 0 16px 40px -22px oklch(0.22 0.025 265/.9);
  opacity: 0;
  translate: 0 10px;
  pointer-events: none;
  transition: opacity .24s ease,translate .3s cubic-bezier(.2,1.1,.3,1);
}

.fb-33__dock[data-open="true"] .fb-33__strip {
  opacity: 1;
  translate: 0 0;
  pointer-events: auto;
}

.fb-33__react {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  font-size: 24px;
  line-height: 1;
  scale: 0;
  translate: 0 12px;
  transform-origin: 50% 100%;
  transition: scale .34s cubic-bezier(.2,1.6,.4,1),translate .3s cubic-bezier(.2,1.4,.4,1),background .18s ease;
  transition-delay: calc(var(--fb-33-i) * 42ms);
}

.fb-33__dock[data-open="true"] .fb-33__react {
  scale: 1;
  translate: 0 0;
}

.fb-33__react span {
  display: block;
  filter: drop-shadow(0 2px 4px oklch(0.22 0.025 265/.2));
}

.fb-33__dock[data-open="true"] .fb-33__react:hover {
  scale: 1.5;
  translate: 0 -10px;
  transition-delay: 0s;
}

.fb-33__dock[data-open="true"] li:hover + li .fb-33__react,
.fb-33__dock[data-open="true"] li:has(+ li:hover) .fb-33__react {
  scale: 1.24;
  translate: 0 -5px;
  transition-delay: 0s;
}

.fb-33__react:focus-visible {
  outline: 3px solid var(--fb-33-brand);
  outline-offset: 2px;
  scale: 1.35;
}

.fb-33__react[aria-pressed="true"] {
  background: oklch(0.5 0.16 272/.14);
}

.fb-33__burst {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.fb-33__burst i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--fb-33-brand);
  animation: fb-33-fly .62s cubic-bezier(.2,.7,.3,1) forwards;
}

@keyframes fb-33-fly {
  to {
    translate: calc(cos(var(--fb-33-a)) * 42px) calc(sin(var(--fb-33-a)) * -42px);
    opacity: 0;
    scale: .3;
  }
}

.fb-33__fab {
  display: grid;
  place-items: center;
  inline-size: 58px;
  block-size: 58px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  color: oklch(0.99 0.01 272);
  background: linear-gradient(150deg,oklch(0.6 0.17 276),var(--fb-33-brand));
  box-shadow: 0 12px 30px -12px oklch(0.5 0.16 272/.85),0 2px 6px oklch(0.3 0.07 272/.28);
  transition: transform .28s cubic-bezier(.2,1.3,.4,1),rotate .3s ease;
}

.fb-33__fab svg {
  width: 26px;
  height: 26px;
}

.fb-33__fab:hover {
  transform: translateY(-2px) scale(1.04);
}

.fb-33__fab:active {
  transform: scale(.94);
}

.fb-33__fab:focus-visible {
  outline: 3px solid var(--fb-33-brand);
  outline-offset: 4px;
}

.fb-33__dock[data-open="true"] .fb-33__fab {
  rotate: 22deg;
}

.fb-33__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--fb-33-brand);
  min-height: 18px;
}

.fb-33__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--fb-33-mut);
  padding: 8px 14px;
  border: 1px solid oklch(0.22 0.025 265/.14);
  border-radius: 99px;
}

@media (prefers-reduced-motion: reduce) {
  .fb-33 * {
    transition-duration: .01ms !important;
    animation: none !important;
  }
}
```

## JavaScript
```js
(() => {
  const dock = document.getElementById('fb-33-dock');
  const fab = document.getElementById('fb-33-fab');
  const tally = document.getElementById('fb-33-tally');
  const live = document.getElementById('fb-33-live');
  if (!dock || !fab || fab.dataset.fbWired) return;
  fab.dataset.fbWired = '1';
  const items = [...dock.querySelectorAll('.fb-33__react')];
  const counts = new Map();
  const set = (open) => {
    dock.dataset.open = String(open);
    fab.setAttribute('aria-expanded', String(open));
    items.forEach((i) => { i.tabIndex = open ? 0 : -1; });
    if (open) setTimeout(() => items[0].focus(), 90);
  };
  const render = () => {
    tally.innerHTML = '';
    counts.forEach((n, e) => {
      if (!n) return;
      const li = document.createElement('li');
      li.textContent = e + ' ' + n;
      tally.append(li);
    });
  };
  const burst = (btn) => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const wrap = document.createElement('span');
    wrap.className = 'fb-33__burst';
    for (let i = 0; i < 8; i += 1) {
      const p = document.createElement('i');
      p.style.setProperty('--fb-33-a', (i * 45) + 'deg');
      wrap.append(p);
    }
    btn.append(wrap);
    setTimeout(() => wrap.remove(), 700);
  };
  items.forEach((btn, idx) => {
    btn.setAttribute('aria-label', btn.dataset.fbN + ', 0 reactions');
    btn.addEventListener('click', () => {
      const on = btn.getAttribute('aria-pressed') === 'true';
      const e = btn.dataset.fbE;
      const n = (counts.get(e) || 0) + (on ? -1 : 1);
      counts.set(e, Math.max(0, n));
      btn.setAttribute('aria-pressed', String(!on));
      btn.setAttribute('aria-label', btn.dataset.fbN + ', ' + counts.get(e) + ' reactions');
      if (!on) burst(btn);
      render();
      live.textContent = (on ? 'Removed ' : 'Added ') + btn.dataset.fbN;
    });
    btn.addEventListener('keydown', (ev) => {
      if (ev.key === 'ArrowRight') { ev.preventDefault(); items[(idx + 1) % items.length].focus(); }
      if (ev.key === 'ArrowLeft') { ev.preventDefault(); items[(idx - 1 + items.length) % items.length].focus(); }
      if (ev.key === 'Escape') { set(false); fab.focus(); }
    });
  });
  fab.addEventListener('click', () => set(dock.dataset.open !== 'true'));
})();
```

How this works

Two independent behaviours share one strip. Entry is a staggered spring; magnification is proximity-based:

/* entry */
.react{ scale:0; translate:0 12px; transition:scale .34s cubic-bezier(.2,1.6,.4,1);
        transition-delay:calc(var(--i) * 42ms) }
[data-open="true"] .react{ scale:1; translate:0 0 }
/* dock magnify */
.react:hover{ scale:1.5; translate:0 -10px }
.react:hover + .react, .react:has(+ .react:hover){ scale:1.25; translate:0 -5px }

The neighbour rules are what make it feel like a dock rather than a row of buttons: the hovered item grows most, its immediate siblings grow less. :has(+ .react:hover) reaches backwards to the previous sibling — something CSS could not do before :has() and which used to require JavaScript.

The burst is eight particles positioned with cos()/sin() from an index, animated outward once and removed on animationend. Generating them in JS keeps the markup clean; doing the geometry in CSS keeps the JS to five lines.

Each reaction is a toggle: aria-pressed tracks whether you reacted, the count updates in the accessible name, and the strip supports Arrow keys with wrap-around plus Escape to close. Reaction bars that are div soup are unusable with a screen reader — and reactions are exactly the kind of thing people want to check without looking.

Make it yours

  • Reaction set: six is the maximum before the strip gets wider than a thumb's reach. Put the most-used first, not the most expressive.
  • Skin tones / variants: long-press a reaction to open a second row, the same way messaging apps handle variants.
  • Counts: show them under each reaction once any is above zero; hide the row entirely at zero to avoid an empty scoreboard.
  • Custom emoji: swap the glyphs for SVG or images of team emoji — the dock magnification works identically.

Gotchas — read before shipping

  • Emoji render at wildly different sizes across platforms. Set a fixed font-size and line-height on the glyph and centre it in a fixed box.
  • Do not animate scale on the strip container; the magnification must be per-item or the neighbours all move together.
  • Particle bursts must clean themselves up. Removing them on animationend prevents hundreds of orphaned nodes after heavy use.
  • A reaction that only communicates via colour or scale is invisible to screen readers. aria-pressed plus a count in the label is the minimum.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

:has() needs Chrome 105+, Safari 15.4+, Firefox 121+; without it the dock magnification affects only the hovered item, which still works fine. CSS trig functions need Chrome 111 / Safari 15.4 / Firefox 108.

Search CodeFronts

Loading…