34 CSS Floating Buttons
20 / 34

All 34 in 34 CSS Floating Buttons

  1. 01Material Design 3 Floating Action Button (FAB)
  2. 02iOS-Style Compose Floating Button
  3. 03WhatsApp Floating Message Button CSS
  4. 04Intercom / Drift Chat Bubble Floating Button
  5. 05Accessible WAI-ARIA Floating Action Button (WCAG 2.2)
  6. 06Extended FAB with Icon and Label (Material Design 3)
  7. 07Speed Dial Radial Menu FAB with Backdrop
  8. 08Pure CSS FAB Speed Dial (No JavaScript)
  9. 09Material Gradient Floating Action Button CSS
  10. 10Vertical and Horizontal Speed Dial FAB Menu CSS
  11. 11Morphing FAB Button That Expands to Pill CSS
  12. 12Scroll-to-Top Floating Button with SVG Progress Ring
  13. 13Floating Chat Widget FAB with Notification Panel
  14. 14Fan and Arc Radial Spread FAB Menu CSS
  15. 15Apple Squircle Superellipse Floating Button CSS
  16. 16Social Share Floating Button Cluster CSS
  17. 17Glassmorphism Floating Action Button with Backdrop Blur
  18. 18Cyberpunk Neon Glow Floating Button CSS
  19. 19Floating Compose Button with Tooltip on Hover CSS
  20. 20Draggable Floating Action Button with Corner Snap CSS
  21. 21Notification Bell Floating Button with Badge Counter
  22. 22Floating Cart Button with Live Badge Counter CSS
  23. 23Magnetic Cursor Follower Floating Button CSS
  24. 24Dark Mode Theme Toggle Floating Button CSS
  25. 25Classic Gradient Circle Plus FAB CSS
  26. 26Pulse Ring Attention-Getter Floating Button CSS
  27. 27Pill-Shaped FAB with Icon and Text Label CSS
  28. 28Neo-Brutalist Hard Shadow Floating Button CSS
  29. 29Floating Button with Loading Spinner State CSS
  30. 30Draggable Floating Button with 6-Dot Grip Handle CSS
  31. 31Voice Recording Microphone Floating Button CSS
  32. 32Aurora Gradient Drift Premium Floating Button CSS
  33. 33Quick Emoji Reply Reaction Floating Button CSS
  34. 34Modern Squircle Rounded Square FAB CSS
Light JSMIT licensed

Draggable Floating Action Button with Corner Snap CSS

A FAB the user can move out of the way — drag it anywhere, release, and it flies to the nearest corner with a spring. Uses Pointer Events (one code path for mouse, touch and pen), setPointerCapture so the drag survives leaving the element, keyboard arrows as a full alternative, and it remembers the chosen corner for the session.

Live Demo
Try it

Nucleo: 44,000+ SVG icons in five families · All Icons Bundle $149

Nucleo · Affiliate link

Get the Nucleo bundle

The code

<section class="fb-20" aria-label="Draggable floating action button demo">
  <div class="fb-20__field" id="fb-20-field">
    <div class="fb-20__page">
      <p class="fb-20__eyebrow">setPointerCapture · corner snap</p>
      <h2 class="fb-20__title">Drag it. Let go. It finds a corner.</h2>
      <p class="fb-20__sub">One pointer code path for mouse, touch and pen. Arrow keys nudge it, Shift+arrow throws it to an edge, and every move is announced.</p>
      <p class="fb-20__live" id="fb-20-live" role="status" aria-live="polite">Docked bottom right</p>
      <ul class="fb-20__keys"><li><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> nudge 24px</li><li><kbd>Shift</kbd> + arrow throw to edge</li></ul>
    </div>
    <button class="fb-20__fab" id="fb-20-fab" type="button" aria-label="Assistive menu, draggable. Use arrow keys to move." data-corner="br">
      <span class="fb-20__grip" aria-hidden="true"></span>
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="3.2" fill="currentColor"/><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-dasharray="3 4"/></svg>
    </button>
  </div>
  <p class="fb-20__chip">touch-action:none · transition on release only · 24px nudge</p>
</section>
.fb-20 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-20-bg);
  --fb-20-bg: oklch(0.96 0.01 155);
  --fb-20-brand: oklch(0.48 0.13 165);
  --fb-20-ink: oklch(0.22 0.025 165);
  --fb-20-mut: oklch(0.52 0.015 165);
  --fb-20-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-20-ink);
  display: grid;
  place-items: center;
  align-content: center;
  gap: 14px;
  padding: clamp(18px,4vw,44px);
}

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

.fb-20__field {
  position: relative;
  width: min(100%,720px);
  height: min(70svh,560px);
  border-radius: 26px;
  overflow: hidden;
  background: var(--fb-20-card);
  border: 1px solid oklch(0.22 0.025 165/.1);
  box-shadow: 0 26px 60px -34px oklch(0.22 0.025 165/.85);
  background-image: radial-gradient(oklch(0.22 0.025 165/.07) 1px,transparent 1px);
  background-size: 22px 22px;
  display: grid;
  place-items: center;
  padding: clamp(20px,4vw,44px);
  touch-action: none;
}

.fb-20__page {
  width: min(100%,48ch);
  display: grid;
  gap: 12px;
  justify-items: center;
  text-align: center;
}

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

.fb-20__title {
  font-size: clamp(23px,4vw,38px);
  line-height: 1.06;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-20__sub {
  font-size: clamp(13.5px,1.6vw,16px);
  line-height: 1.62;
  color: var(--fb-20-mut);
  text-wrap: pretty;
}

.fb-20__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-20-brand);
  background: oklch(0.48 0.13 165/.1);
  border: 1px dashed oklch(0.48 0.13 165/.4);
  border-radius: 10px;
  padding: 7px 14px;
}

.fb-20__keys {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  font-size: 11.5px;
  color: var(--fb-20-mut);
}

.fb-20__keys li {
  display: flex;
  align-items: center;
  gap: 4px;
  background: oklch(0.97 0.008 165);
  border: 1px solid oklch(0.22 0.025 165/.1);
  border-radius: 99px;
  padding: 5px 11px;
}

.fb-20 kbd {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 10.5px;
  background: var(--fb-20-card);
  border: 1px solid oklch(0.22 0.025 165/.16);
  border-bottom-width: 2px;
  border-radius: 5px;
  padding: 1px 5px;
}

.fb-20__fab {
  position: absolute;
  z-index: 4;
  display: grid;
  place-items: center;
  inline-size: 62px;
  block-size: 62px;
  border: 0;
  border-radius: 20px;
  cursor: grab;
  touch-action: none;
  color: oklch(0.99 0.01 165);
  background: linear-gradient(150deg,oklch(0.62 0.14 160),var(--fb-20-brand));
  box-shadow: 0 14px 30px -12px oklch(0.35 0.11 165/.9),0 2px 6px oklch(0.25 0.05 165/.3);
  transition: left .46s cubic-bezier(.2,1.3,.35,1),top .46s cubic-bezier(.2,1.3,.35,1),scale .2s ease,box-shadow .25s ease;
}

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

.fb-20__fab[data-dragging="true"] {
  cursor: grabbing;
  transition: none;
  scale: 1.08;
  box-shadow: 0 26px 50px -14px oklch(0.35 0.11 165/.95);
}

.fb-20__fab:hover {
  box-shadow: 0 18px 38px -12px oklch(0.35 0.11 165/.95);
}

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

.fb-20__grip {
  position: absolute;
  top: 6px;
  left: 50%;
  translate: -50% 0;
  width: 22px;
  height: 3px;
  border-radius: 99px;
  background: oklch(1 0 0/.45);
}

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

@media (prefers-reduced-motion: reduce) {
  .fb-20__fab {
    transition: none;
  }
}
(() => {
  const field = document.getElementById('fb-20-field');
  const fab = document.getElementById('fb-20-fab');
  const live = document.getElementById('fb-20-live');
  if (!field || !fab || fab.dataset.fbWired) return;
  fab.dataset.fbWired = '1';
  const PAD = 16;
  const names = { tl: 'top left', tr: 'top right', bl: 'bottom left', br: 'bottom right' };
  let drag = null, moved = 0;
  const bounds = () => {
    const f = field.getBoundingClientRect();
    return { w: f.width - fab.offsetWidth - PAD, h: f.height - fab.offsetHeight - PAD, rect: f };
  };
  const place = (x, y) => {
    const b = bounds();
    fab.style.left = Math.min(Math.max(x, PAD), b.w) + 'px';
    fab.style.top = Math.min(Math.max(y, PAD), b.h) + 'px';
  };
  const snap = (corner) => {
    const b = bounds();
    fab.dataset.corner = corner;
    fab.style.left = (corner === 'tl' || corner === 'bl' ? PAD : b.w) + 'px';
    fab.style.top = (corner === 'tl' || corner === 'tr' ? PAD : b.h) + 'px';
    live.textContent = 'Docked ' + names[corner];
  };
  const nearest = () => {
    const b = bounds();
    const x = parseFloat(fab.style.left) || 0, y = parseFloat(fab.style.top) || 0;
    return (y < b.h / 2 ? 't' : 'b') + (x < b.w / 2 ? 'l' : 'r');
  };
  fab.addEventListener('pointerdown', (e) => {
    const r = fab.getBoundingClientRect(), f = field.getBoundingClientRect();
    fab.setPointerCapture(e.pointerId);
    fab.dataset.dragging = 'true';
    moved = 0;
    drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, fx: f.left, fy: f.top };
  });
  fab.addEventListener('pointermove', (e) => {
    if (!drag) return;
    moved += Math.abs(e.movementX) + Math.abs(e.movementY);
    place(e.clientX - drag.dx - drag.fx, e.clientY - drag.dy - drag.fy);
  });
  const end = () => { if (!drag) return; drag = null; fab.dataset.dragging = 'false'; snap(nearest()); };
  fab.addEventListener('pointerup', end);
  fab.addEventListener('pointercancel', end);
  fab.addEventListener('click', (e) => { if (moved > 6) { e.preventDefault(); e.stopPropagation(); moved = 0; } });
  fab.addEventListener('keydown', (e) => {
    const map = { ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24] };
    if (!map[e.key]) return;
    e.preventDefault();
    const b = bounds();
    if (e.shiftKey) {
      const c = fab.dataset.corner || 'br';
      const vert = c[0], horiz = c[1];
      if (e.key === 'ArrowLeft') snap(vert + 'l');
      else if (e.key === 'ArrowRight') snap(vert + 'r');
      else if (e.key === 'ArrowUp') snap('t' + horiz);
      else snap('b' + horiz);
      return;
    }
    const x = (parseFloat(fab.style.left) || b.w) + map[e.key][0];
    const y = (parseFloat(fab.style.top) || b.h) + map[e.key][1];
    place(x, y);
    live.textContent = 'Moved to ' + Math.round(parseFloat(fab.style.left)) + ', ' + Math.round(parseFloat(fab.style.top));
  });
  requestAnimationFrame(() => snap('br'));
  window.addEventListener('resize', () => snap(fab.dataset.corner || 'br'));
})();
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: Draggable Floating Action Button with Corner Snap CSS
Source: https://codefronts.com/components/css-floating-buttons/draggable-fab/

A FAB the user can move out of the way — drag it anywhere, release, and it flies to the nearest corner with a spring. Uses Pointer Events (one code path for mouse, touch and pen), setPointerCapture so the drag survives leaving the element, keyboard arrows as a full alternative, and it remembers the chosen corner for the session.
## HTML
```html
<section class="fb-20" aria-label="Draggable floating action button demo">
  <div class="fb-20__field" id="fb-20-field">
    <div class="fb-20__page">
      <p class="fb-20__eyebrow">setPointerCapture · corner snap</p>
      <h2 class="fb-20__title">Drag it. Let go. It finds a corner.</h2>
      <p class="fb-20__sub">One pointer code path for mouse, touch and pen. Arrow keys nudge it, Shift+arrow throws it to an edge, and every move is announced.</p>
      <p class="fb-20__live" id="fb-20-live" role="status" aria-live="polite">Docked bottom right</p>
      <ul class="fb-20__keys"><li><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> nudge 24px</li><li><kbd>Shift</kbd> + arrow throw to edge</li></ul>
    </div>
    <button class="fb-20__fab" id="fb-20-fab" type="button" aria-label="Assistive menu, draggable. Use arrow keys to move." data-corner="br">
      <span class="fb-20__grip" aria-hidden="true"></span>
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="3.2" fill="currentColor"/><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-dasharray="3 4"/></svg>
    </button>
  </div>
  <p class="fb-20__chip">touch-action:none · transition on release only · 24px nudge</p>
</section>
```
## CSS
```css
.fb-20 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-20-bg);
  --fb-20-bg: oklch(0.96 0.01 155);
  --fb-20-brand: oklch(0.48 0.13 165);
  --fb-20-ink: oklch(0.22 0.025 165);
  --fb-20-mut: oklch(0.52 0.015 165);
  --fb-20-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-20-ink);
  display: grid;
  place-items: center;
  align-content: center;
  gap: 14px;
  padding: clamp(18px,4vw,44px);
}

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

.fb-20__field {
  position: relative;
  width: min(100%,720px);
  height: min(70svh,560px);
  border-radius: 26px;
  overflow: hidden;
  background: var(--fb-20-card);
  border: 1px solid oklch(0.22 0.025 165/.1);
  box-shadow: 0 26px 60px -34px oklch(0.22 0.025 165/.85);
  background-image: radial-gradient(oklch(0.22 0.025 165/.07) 1px,transparent 1px);
  background-size: 22px 22px;
  display: grid;
  place-items: center;
  padding: clamp(20px,4vw,44px);
  touch-action: none;
}

.fb-20__page {
  width: min(100%,48ch);
  display: grid;
  gap: 12px;
  justify-items: center;
  text-align: center;
}

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

.fb-20__title {
  font-size: clamp(23px,4vw,38px);
  line-height: 1.06;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-20__sub {
  font-size: clamp(13.5px,1.6vw,16px);
  line-height: 1.62;
  color: var(--fb-20-mut);
  text-wrap: pretty;
}

.fb-20__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-20-brand);
  background: oklch(0.48 0.13 165/.1);
  border: 1px dashed oklch(0.48 0.13 165/.4);
  border-radius: 10px;
  padding: 7px 14px;
}

.fb-20__keys {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  font-size: 11.5px;
  color: var(--fb-20-mut);
}

.fb-20__keys li {
  display: flex;
  align-items: center;
  gap: 4px;
  background: oklch(0.97 0.008 165);
  border: 1px solid oklch(0.22 0.025 165/.1);
  border-radius: 99px;
  padding: 5px 11px;
}

.fb-20 kbd {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 10.5px;
  background: var(--fb-20-card);
  border: 1px solid oklch(0.22 0.025 165/.16);
  border-bottom-width: 2px;
  border-radius: 5px;
  padding: 1px 5px;
}

.fb-20__fab {
  position: absolute;
  z-index: 4;
  display: grid;
  place-items: center;
  inline-size: 62px;
  block-size: 62px;
  border: 0;
  border-radius: 20px;
  cursor: grab;
  touch-action: none;
  color: oklch(0.99 0.01 165);
  background: linear-gradient(150deg,oklch(0.62 0.14 160),var(--fb-20-brand));
  box-shadow: 0 14px 30px -12px oklch(0.35 0.11 165/.9),0 2px 6px oklch(0.25 0.05 165/.3);
  transition: left .46s cubic-bezier(.2,1.3,.35,1),top .46s cubic-bezier(.2,1.3,.35,1),scale .2s ease,box-shadow .25s ease;
}

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

.fb-20__fab[data-dragging="true"] {
  cursor: grabbing;
  transition: none;
  scale: 1.08;
  box-shadow: 0 26px 50px -14px oklch(0.35 0.11 165/.95);
}

.fb-20__fab:hover {
  box-shadow: 0 18px 38px -12px oklch(0.35 0.11 165/.95);
}

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

.fb-20__grip {
  position: absolute;
  top: 6px;
  left: 50%;
  translate: -50% 0;
  width: 22px;
  height: 3px;
  border-radius: 99px;
  background: oklch(1 0 0/.45);
}

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

@media (prefers-reduced-motion: reduce) {
  .fb-20__fab {
    transition: none;
  }
}
```

## JavaScript
```js
(() => {
  const field = document.getElementById('fb-20-field');
  const fab = document.getElementById('fb-20-fab');
  const live = document.getElementById('fb-20-live');
  if (!field || !fab || fab.dataset.fbWired) return;
  fab.dataset.fbWired = '1';
  const PAD = 16;
  const names = { tl: 'top left', tr: 'top right', bl: 'bottom left', br: 'bottom right' };
  let drag = null, moved = 0;
  const bounds = () => {
    const f = field.getBoundingClientRect();
    return { w: f.width - fab.offsetWidth - PAD, h: f.height - fab.offsetHeight - PAD, rect: f };
  };
  const place = (x, y) => {
    const b = bounds();
    fab.style.left = Math.min(Math.max(x, PAD), b.w) + 'px';
    fab.style.top = Math.min(Math.max(y, PAD), b.h) + 'px';
  };
  const snap = (corner) => {
    const b = bounds();
    fab.dataset.corner = corner;
    fab.style.left = (corner === 'tl' || corner === 'bl' ? PAD : b.w) + 'px';
    fab.style.top = (corner === 'tl' || corner === 'tr' ? PAD : b.h) + 'px';
    live.textContent = 'Docked ' + names[corner];
  };
  const nearest = () => {
    const b = bounds();
    const x = parseFloat(fab.style.left) || 0, y = parseFloat(fab.style.top) || 0;
    return (y < b.h / 2 ? 't' : 'b') + (x < b.w / 2 ? 'l' : 'r');
  };
  fab.addEventListener('pointerdown', (e) => {
    const r = fab.getBoundingClientRect(), f = field.getBoundingClientRect();
    fab.setPointerCapture(e.pointerId);
    fab.dataset.dragging = 'true';
    moved = 0;
    drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, fx: f.left, fy: f.top };
  });
  fab.addEventListener('pointermove', (e) => {
    if (!drag) return;
    moved += Math.abs(e.movementX) + Math.abs(e.movementY);
    place(e.clientX - drag.dx - drag.fx, e.clientY - drag.dy - drag.fy);
  });
  const end = () => { if (!drag) return; drag = null; fab.dataset.dragging = 'false'; snap(nearest()); };
  fab.addEventListener('pointerup', end);
  fab.addEventListener('pointercancel', end);
  fab.addEventListener('click', (e) => { if (moved > 6) { e.preventDefault(); e.stopPropagation(); moved = 0; } });
  fab.addEventListener('keydown', (e) => {
    const map = { ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24] };
    if (!map[e.key]) return;
    e.preventDefault();
    const b = bounds();
    if (e.shiftKey) {
      const c = fab.dataset.corner || 'br';
      const vert = c[0], horiz = c[1];
      if (e.key === 'ArrowLeft') snap(vert + 'l');
      else if (e.key === 'ArrowRight') snap(vert + 'r');
      else if (e.key === 'ArrowUp') snap('t' + horiz);
      else snap('b' + horiz);
      return;
    }
    const x = (parseFloat(fab.style.left) || b.w) + map[e.key][0];
    const y = (parseFloat(fab.style.top) || b.h) + map[e.key][1];
    place(x, y);
    live.textContent = 'Moved to ' + Math.round(parseFloat(fab.style.left)) + ', ' + Math.round(parseFloat(fab.style.top));
  });
  requestAnimationFrame(() => snap('br'));
  window.addEventListener('resize', () => snap(fab.dataset.corner || 'br'));
})();
```

How this works

Three handlers and one snap function:

el.addEventListener('pointerdown', e => {
  el.setPointerCapture(e.pointerId);        // keep events even outside the box
  drag = { dx:e.clientX - box.left, dy:e.clientY - box.top };
});
el.addEventListener('pointermove', e => { if (drag) place(e.clientX-drag.dx, e.clientY-drag.dy) });
el.addEventListener('pointerup', () => { drag = null; snapToNearestCorner() });

setPointerCapture is what makes the drag feel solid: without it, moving faster than the browser can repaint drops the pointer outside the element and the drag dies mid-gesture. It also unifies mouse and touch, so no duplicate touch handlers and no preventDefault gymnastics.

During the drag, transitions are off and the position is written directly. On release, a transition is switched back on and the target corner is assigned — so the snap is a CSS spring (cubic-bezier(.2,1.3,.35,1)) rather than a JS animation loop. Cheap, interruptible, and it respects reduced motion for free.

Keyboard parity is non-negotiable for a movable control: arrows nudge by 24px, Shift+arrow jumps straight to that edge, and each move announces the new position through a live region. A drag-only affordance is unusable for anyone who cannot drag.

Make it yours

  • Snap targets: swap the four corners for the two side edges (left/right only) — the pattern most mobile assistive buttons use.
  • Edge peek: after 3s idle, translate the button 40% off-screen and restore on hover, so it stops covering content entirely.
  • Persistence: this demo keeps the corner in a variable; in production write it to localStorage keyed per user so the choice survives reloads.
  • Drag threshold: require 6px of movement before treating a pointerdown as a drag, so a slightly shaky tap still counts as a click.

Gotchas — read before shipping

  • Without touch-action:none on the draggable element, mobile browsers scroll the page instead of dragging it.
  • A drag that also fires the click handler on release is the classic bug. Track movement distance and suppress the click when it exceeds the threshold.
  • Clamp the snap position against the viewport and the safe-area insets, or the button can land under a notch or home indicator.
  • Movable elements must expose their position to assistive tech. A live region announcing "moved to bottom left" is the minimum.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

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

Pointer Events are supported in every modern browser (Chrome 55+, Safari 13+, Firefox 59+). touch-action is equally broad. No polyfill needed.

Search CodeFronts

Loading…