34 CSS Floating Buttons20 / 34
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.
Published Updated
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><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;
}
}.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'));
})();(() => {
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'));
})();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'));
})();
```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:noneon 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
| 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 are supported in every modern browser (Chrome 55+, Safari 13+, Firefox 59+). touch-action is equally broad. No polyfill needed.