34 CSS Floating Buttons
30 / 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 Button with 6-Dot Grip Handle CSS

When a floating control is both draggable and clickable, the two gestures fight. The fix is a dedicated grip: six dots along the top edge that own the drag, leaving the button body purely a button. Adds an idle behaviour that tucks the widget 60% off the nearest edge after a few seconds so it stops covering content, and slides back on approach.

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-30" aria-label="Draggable floating button with grip handle demo">
  <div class="fb-30__field" id="fb-30-field">
    <div class="fb-30__page">
      <p class="fb-30__eyebrow">grip owns the drag · body owns the click</p>
      <h2 class="fb-30__title">Two gestures, two targets</h2>
      <p class="fb-30__sub">Drag from the dots. Click the body. No thresholds, no suppressed clicks, no accidental drags — and it tucks itself away when you stop touching it.</p>
      <p class="fb-30__live" id="fb-30-live" role="status" aria-live="polite">Docked right · idle tuck in 2.6s</p>
    </div>
    <div class="fb-30__widget" id="fb-30-widget" data-tucked="false">
      <span class="fb-30__grip" id="fb-30-grip" role="button" tabindex="0" aria-label="Move widget. Use arrow keys after focusing."></span>
      <button class="fb-30__btn" id="fb-30-btn" type="button" aria-label="Open assistant">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3.6l1.9 4.6 4.6 1.9-4.6 1.9L12 16.6l-1.9-4.6L5.5 10.1l4.6-1.9z" fill="currentColor"/><circle cx="18.4" cy="17.6" r="2.1" fill="currentColor"/></svg>
      </button>
      <span class="fb-30__hint" aria-hidden="true">Assist</span>
    </div>
  </div>
  <p class="fb-30__chip">radial-gradient grip · 2.6s idle tuck · focus cancels</p>
</section>
.fb-30 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-30-bg);
  --fb-30-bg: oklch(0.96 0.012 285);
  --fb-30-brand: oklch(0.46 0.16 292);
  --fb-30-ink: oklch(0.22 0.025 290);
  --fb-30-mut: oklch(0.53 0.015 290);
  --fb-30-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-30-ink);
  display: grid;
  place-items: center;
  align-content: center;
  gap: 14px;
  padding: clamp(18px,4vw,44px);
}

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

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

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

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

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

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

.fb-30__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-30-brand);
  background: oklch(0.46 0.16 292/.1);
  border: 1px dashed oklch(0.46 0.16 292/.35);
  border-radius: 10px;
  padding: 7px 14px;
}

.fb-30__widget {
  position: absolute;
  z-index: 4;
  display: grid;
  justify-items: center;
  gap: 4px;
  padding: 6px 6px 8px;
  border-radius: 22px;
  background: var(--fb-30-card);
  border: 1px solid oklch(0.22 0.025 290/.1);
  box-shadow: 0 16px 36px -18px oklch(0.22 0.025 290/.9);
  transition: translate .42s cubic-bezier(.2,.9,.3,1),opacity .42s ease,left .3s ease,top .3s ease;
}

.fb-30__widget[data-dragging="true"] {
  transition: none;
  box-shadow: 0 26px 50px -16px oklch(0.22 0.025 290/.95);
  scale: 1.04;
}

.fb-30__widget[data-tucked="true"] {
  translate: 58% 0;
  opacity: .55;
}

.fb-30__widget:hover,
.fb-30__widget:focus-within {
  translate: 0 0;
  opacity: 1;
}

.fb-30__grip {
  width: 34px;
  height: 16px;
  cursor: grab;
  touch-action: none;
  border-radius: 6px;
  background-image: radial-gradient(circle,oklch(0.22 0.025 290/.42) 1.1px,transparent 1.2px);
  background-size: 7px 7px;
  background-position: center;
  background-repeat: repeat;
}

.fb-30__grip:active {
  cursor: grabbing;
}

.fb-30__grip:focus-visible {
  outline: 2.5px solid var(--fb-30-brand);
  outline-offset: 2px;
}

.fb-30__btn {
  display: grid;
  place-items: center;
  inline-size: 56px;
  block-size: 56px;
  border: 0;
  border-radius: 18px;
  cursor: pointer;
  color: oklch(0.99 0.01 292);
  background: linear-gradient(150deg,oklch(0.6 0.18 296),var(--fb-30-brand));
  box-shadow: 0 10px 24px -12px oklch(0.46 0.16 292/.9);
  transition: transform .26s cubic-bezier(.2,1.3,.4,1);
}

.fb-30__btn svg {
  width: 25px;
  height: 25px;
}

.fb-30__btn:hover {
  transform: translateY(-1px) scale(1.03);
}

.fb-30__btn:active {
  transform: scale(.94);
}

.fb-30__btn:focus-visible {
  outline: 3px solid var(--fb-30-brand);
  outline-offset: 3px;
}

.fb-30__hint {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 9.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--fb-30-mut);
}

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

@media (prefers-reduced-motion: reduce) {
  .fb-30__widget {
    transition: none;
  }

  .fb-30__widget[data-tucked="true"] {
    translate: 0 0;
    opacity: 1;
  }
}
(() => {
  const field = document.getElementById('fb-30-field');
  const widget = document.getElementById('fb-30-widget');
  const grip = document.getElementById('fb-30-grip');
  const live = document.getElementById('fb-30-live');
  if (!field || !widget || widget.dataset.fbWired) return;
  widget.dataset.fbWired = '1';
  const PAD = 14;
  const calm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let drag = null, timer = null;
  const limits = () => {
    const f = field.getBoundingClientRect();
    return { w: f.width - widget.offsetWidth - PAD, h: f.height - widget.offsetHeight - PAD, fx: f.left, fy: f.top };
  };
  const place = (x, y) => {
    const l = limits();
    widget.style.left = Math.min(Math.max(x, PAD), l.w) + 'px';
    widget.style.top = Math.min(Math.max(y, PAD), l.h) + 'px';
  };
  const idle = () => {
    clearTimeout(timer);
    widget.dataset.tucked = 'false';
    if (calm) return;
    timer = setTimeout(() => {
      widget.dataset.tucked = 'true';
      live.textContent = 'Tucked to the edge — hover or focus to restore';
    }, 2600);
  };
  grip.addEventListener('pointerdown', (e) => {
    e.preventDefault();
    const r = widget.getBoundingClientRect(), l = limits();
    grip.setPointerCapture(e.pointerId);
    widget.dataset.dragging = 'true';
    drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, fx: l.fx, fy: l.fy };
    idle();
  });
  grip.addEventListener('pointermove', (e) => {
    if (!drag) return;
    place(e.clientX - drag.dx - drag.fx, e.clientY - drag.dy - drag.fy);
  });
  const end = () => {
    if (!drag) return;
    drag = null;
    widget.dataset.dragging = 'false';
    live.textContent = 'Moved to ' + Math.round(parseFloat(widget.style.left)) + ', ' + Math.round(parseFloat(widget.style.top));
    idle();
  };
  grip.addEventListener('pointerup', end);
  grip.addEventListener('pointercancel', end);
  grip.addEventListener('keydown', (e) => {
    const map = { ArrowLeft: [-22, 0], ArrowRight: [22, 0], ArrowUp: [0, -22], ArrowDown: [0, 22] };
    if (!map[e.key]) return;
    e.preventDefault();
    const l = limits();
    place((parseFloat(widget.style.left) || l.w) + map[e.key][0], (parseFloat(widget.style.top) || l.h) + map[e.key][1]);
    live.textContent = 'Moved to ' + Math.round(parseFloat(widget.style.left)) + ', ' + Math.round(parseFloat(widget.style.top));
    idle();
  });
  widget.addEventListener('pointerenter', idle);
  widget.addEventListener('focusin', () => { clearTimeout(timer); widget.dataset.tucked = 'false'; });
  widget.addEventListener('focusout', idle);
  document.getElementById('fb-30-btn').addEventListener('click', () => {
    live.textContent = 'Assistant opened — click fired, no drag';
    idle();
  });
  requestAnimationFrame(() => {
    const l = limits();
    place(l.w, l.h - 40);
    idle();
  });
})();
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 Button with 6-Dot Grip Handle CSS
Source: https://codefronts.com/components/css-floating-buttons/fb12/

When a floating control is both draggable and clickable, the two gestures fight. The fix is a dedicated grip: six dots along the top edge that own the drag, leaving the button body purely a button. Adds an idle behaviour that tucks the widget 60% off the nearest edge after a few seconds so it stops covering content, and slides back on approach.
## HTML
```html
<section class="fb-30" aria-label="Draggable floating button with grip handle demo">
  <div class="fb-30__field" id="fb-30-field">
    <div class="fb-30__page">
      <p class="fb-30__eyebrow">grip owns the drag · body owns the click</p>
      <h2 class="fb-30__title">Two gestures, two targets</h2>
      <p class="fb-30__sub">Drag from the dots. Click the body. No thresholds, no suppressed clicks, no accidental drags — and it tucks itself away when you stop touching it.</p>
      <p class="fb-30__live" id="fb-30-live" role="status" aria-live="polite">Docked right · idle tuck in 2.6s</p>
    </div>
    <div class="fb-30__widget" id="fb-30-widget" data-tucked="false">
      <span class="fb-30__grip" id="fb-30-grip" role="button" tabindex="0" aria-label="Move widget. Use arrow keys after focusing."></span>
      <button class="fb-30__btn" id="fb-30-btn" type="button" aria-label="Open assistant">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3.6l1.9 4.6 4.6 1.9-4.6 1.9L12 16.6l-1.9-4.6L5.5 10.1l4.6-1.9z" fill="currentColor"/><circle cx="18.4" cy="17.6" r="2.1" fill="currentColor"/></svg>
      </button>
      <span class="fb-30__hint" aria-hidden="true">Assist</span>
    </div>
  </div>
  <p class="fb-30__chip">radial-gradient grip · 2.6s idle tuck · focus cancels</p>
</section>
```
## CSS
```css
.fb-30 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-30-bg);
  --fb-30-bg: oklch(0.96 0.012 285);
  --fb-30-brand: oklch(0.46 0.16 292);
  --fb-30-ink: oklch(0.22 0.025 290);
  --fb-30-mut: oklch(0.53 0.015 290);
  --fb-30-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-30-ink);
  display: grid;
  place-items: center;
  align-content: center;
  gap: 14px;
  padding: clamp(18px,4vw,44px);
}

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

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

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

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

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

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

.fb-30__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-30-brand);
  background: oklch(0.46 0.16 292/.1);
  border: 1px dashed oklch(0.46 0.16 292/.35);
  border-radius: 10px;
  padding: 7px 14px;
}

.fb-30__widget {
  position: absolute;
  z-index: 4;
  display: grid;
  justify-items: center;
  gap: 4px;
  padding: 6px 6px 8px;
  border-radius: 22px;
  background: var(--fb-30-card);
  border: 1px solid oklch(0.22 0.025 290/.1);
  box-shadow: 0 16px 36px -18px oklch(0.22 0.025 290/.9);
  transition: translate .42s cubic-bezier(.2,.9,.3,1),opacity .42s ease,left .3s ease,top .3s ease;
}

.fb-30__widget[data-dragging="true"] {
  transition: none;
  box-shadow: 0 26px 50px -16px oklch(0.22 0.025 290/.95);
  scale: 1.04;
}

.fb-30__widget[data-tucked="true"] {
  translate: 58% 0;
  opacity: .55;
}

.fb-30__widget:hover,
.fb-30__widget:focus-within {
  translate: 0 0;
  opacity: 1;
}

.fb-30__grip {
  width: 34px;
  height: 16px;
  cursor: grab;
  touch-action: none;
  border-radius: 6px;
  background-image: radial-gradient(circle,oklch(0.22 0.025 290/.42) 1.1px,transparent 1.2px);
  background-size: 7px 7px;
  background-position: center;
  background-repeat: repeat;
}

.fb-30__grip:active {
  cursor: grabbing;
}

.fb-30__grip:focus-visible {
  outline: 2.5px solid var(--fb-30-brand);
  outline-offset: 2px;
}

.fb-30__btn {
  display: grid;
  place-items: center;
  inline-size: 56px;
  block-size: 56px;
  border: 0;
  border-radius: 18px;
  cursor: pointer;
  color: oklch(0.99 0.01 292);
  background: linear-gradient(150deg,oklch(0.6 0.18 296),var(--fb-30-brand));
  box-shadow: 0 10px 24px -12px oklch(0.46 0.16 292/.9);
  transition: transform .26s cubic-bezier(.2,1.3,.4,1);
}

.fb-30__btn svg {
  width: 25px;
  height: 25px;
}

.fb-30__btn:hover {
  transform: translateY(-1px) scale(1.03);
}

.fb-30__btn:active {
  transform: scale(.94);
}

.fb-30__btn:focus-visible {
  outline: 3px solid var(--fb-30-brand);
  outline-offset: 3px;
}

.fb-30__hint {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 9.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--fb-30-mut);
}

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

@media (prefers-reduced-motion: reduce) {
  .fb-30__widget {
    transition: none;
  }

  .fb-30__widget[data-tucked="true"] {
    translate: 0 0;
    opacity: 1;
  }
}
```

## JavaScript
```js
(() => {
  const field = document.getElementById('fb-30-field');
  const widget = document.getElementById('fb-30-widget');
  const grip = document.getElementById('fb-30-grip');
  const live = document.getElementById('fb-30-live');
  if (!field || !widget || widget.dataset.fbWired) return;
  widget.dataset.fbWired = '1';
  const PAD = 14;
  const calm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let drag = null, timer = null;
  const limits = () => {
    const f = field.getBoundingClientRect();
    return { w: f.width - widget.offsetWidth - PAD, h: f.height - widget.offsetHeight - PAD, fx: f.left, fy: f.top };
  };
  const place = (x, y) => {
    const l = limits();
    widget.style.left = Math.min(Math.max(x, PAD), l.w) + 'px';
    widget.style.top = Math.min(Math.max(y, PAD), l.h) + 'px';
  };
  const idle = () => {
    clearTimeout(timer);
    widget.dataset.tucked = 'false';
    if (calm) return;
    timer = setTimeout(() => {
      widget.dataset.tucked = 'true';
      live.textContent = 'Tucked to the edge — hover or focus to restore';
    }, 2600);
  };
  grip.addEventListener('pointerdown', (e) => {
    e.preventDefault();
    const r = widget.getBoundingClientRect(), l = limits();
    grip.setPointerCapture(e.pointerId);
    widget.dataset.dragging = 'true';
    drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, fx: l.fx, fy: l.fy };
    idle();
  });
  grip.addEventListener('pointermove', (e) => {
    if (!drag) return;
    place(e.clientX - drag.dx - drag.fx, e.clientY - drag.dy - drag.fy);
  });
  const end = () => {
    if (!drag) return;
    drag = null;
    widget.dataset.dragging = 'false';
    live.textContent = 'Moved to ' + Math.round(parseFloat(widget.style.left)) + ', ' + Math.round(parseFloat(widget.style.top));
    idle();
  };
  grip.addEventListener('pointerup', end);
  grip.addEventListener('pointercancel', end);
  grip.addEventListener('keydown', (e) => {
    const map = { ArrowLeft: [-22, 0], ArrowRight: [22, 0], ArrowUp: [0, -22], ArrowDown: [0, 22] };
    if (!map[e.key]) return;
    e.preventDefault();
    const l = limits();
    place((parseFloat(widget.style.left) || l.w) + map[e.key][0], (parseFloat(widget.style.top) || l.h) + map[e.key][1]);
    live.textContent = 'Moved to ' + Math.round(parseFloat(widget.style.left)) + ', ' + Math.round(parseFloat(widget.style.top));
    idle();
  });
  widget.addEventListener('pointerenter', idle);
  widget.addEventListener('focusin', () => { clearTimeout(timer); widget.dataset.tucked = 'false'; });
  widget.addEventListener('focusout', idle);
  document.getElementById('fb-30-btn').addEventListener('click', () => {
    live.textContent = 'Assistant opened — click fired, no drag';
    idle();
  });
  requestAnimationFrame(() => {
    const l = limits();
    place(l.w, l.h - 40);
    idle();
  });
})();
```

How this works

Only the grip starts a drag, so the body's click handler is never ambiguous:

grip.addEventListener('pointerdown', e => {
  e.preventDefault();                       // no text selection, no ghost drag
  grip.setPointerCapture(e.pointerId);
  dragging = true;
});

Splitting the gestures removes every workaround the single-element version needs: no movement threshold, no click suppression, no accidental drags when a tap wobbles. The cost is 14px of chrome, and users understand grip dots instantly — the pattern is borrowed straight from desktop toolbars and mobile reorder lists.

The grip is drawn with one radial-gradient and background-size, not six elements: a 2px dot repeated on a 6px grid, clipped to a 14×10 box. Two declarations for a six-dot handle.

Idle tuck-away is a timer plus a translate: after 2.6s with no interaction the widget slides 58% past its docked edge and drops to 55% opacity; pointerenter, focus, or any drag brings it straight back. Every timer resets on interaction, and the tuck is disabled entirely under reduced motion.

Make it yours

  • Grip position: top edge for a bottom-docked widget, left edge for a side rail. Keep it on the side furthest from the content it might cover.
  • Tuck direction: derive it from which edge is nearest, so a widget on the left tucks left. This demo docks right and tucks right.
  • Grip size: 14×10px is the smallest that reads clearly; pair it with a padded hit area of at least 44px in the drag direction.
  • Snap-back: combine with the corner-snap demo's release logic if you want both a grip and magnetic corners.

Gotchas — read before shipping

  • Call preventDefault on pointerdown in the grip or the browser may start a native drag or text selection instead.
  • A widget that tucks away must never tuck a focused element out of view. Cancel the timer on focusin and restore immediately.
  • Grip dots below about 2px on a light background disappear on low-brightness screens. Test at 30% display brightness.
  • Do not tuck faster than about 2.5s of idle — users who paused to read will find the control moving away from under their pointer.

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, setPointerCapture and radial-gradient backgrounds are supported in every modern browser. No polyfill or library required.

Search CodeFronts

Loading…