34 CSS Floating Buttons30 / 34
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.
Published Updated
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><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;
}
}.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();
});
})();(() => {
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();
});
})();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();
});
})();
```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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 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.