Force Field
import { useId, useRef, useState } from 'react';
const css = `
@property --btn-force-field-r { syntax: '<length>'; inherits: false; initial-value: 0px; }
@keyframes btn-force-field-ripple {
from { --btn-force-field-r: 0px; opacity: 1; }
to { --btn-force-field-r: 240px; opacity: 0; }
}
.btn-force-field {
position: relative;
display: grid;
place-items: center;
width: 280px;
height: 150px;
}
.btn-force-field__field, .btn-force-field__ripple {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.btn-force-field__field {
opacity: 0;
mask-image: radial-gradient(ellipse closest-side, #000 35%, transparent);
transition: opacity 0.4s ease;
}
.btn-force-field__field path { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.btn-force-field__ripple path { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 8%, transparent); stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.2; }
.btn-force-field__ripple {
opacity: 0;
mask-image: radial-gradient(circle at var(--x) var(--y), transparent calc(var(--btn-force-field-r) - 30px), #000 calc(var(--btn-force-field-r) - 8px), transparent var(--btn-force-field-r));
}
.btn-force-field__ripple { animation: btn-force-field-ripple 0.9s cubic-bezier(0.22, 1, 0.36, 1); }
.btn-force-field:hover .btn-force-field__field,
.btn-force-field:focus-within .btn-force-field__field { opacity: 1; }
.btn-force-field__btn {
position: relative;
padding: 0.8rem 1.9rem;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.btn-force-field:hover .btn-force-field__btn { border-color: light-dark(#5f626a, #a7abb2); box-shadow: 0 0 20px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent); }
.btn-force-field__btn:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.btn-force-field__ripple { animation: none; }
}
`;
// One honeycomb tile: a pointy-top hexagon plus the stem down to the next row's,
// which together tile the plane with the offset rows filled in.
const HEX = 'M10.39 0L20.78 6V18L10.39 24L0 18V6Z M10.39 24V36';
function Honeycomb({ id, className, style }: { id: string; className: string; style?: React.CSSProperties }) {
return (
<svg className={className} style={style} aria-hidden="true">
<defs>
<pattern id={id} width="20.78" height="36" patternUnits="userSpaceOnUse" x="-0.5" y="3">
<path d={HEX} />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
</svg>
);
}
export function ForceFieldButton() {
const uid = useId().replace(/:/g, '');
const root = useRef<HTMLDivElement>(null);
const [hit, setHit] = useState<{ x: number; y: number; n: number } | null>(null);
return (
<>
<style>
{css}
</style>
<div ref={root} className="btn-force-field">
<Honeycomb id={`${uid}-f`} className="btn-force-field__field" />
{hit && (
<Honeycomb
key={hit.n}
id={`${uid}-r${hit.n}`}
className="btn-force-field__ripple"
style={{ '--x': `${hit.x}px`, '--y': `${hit.y}px` } as React.CSSProperties}
/>
)}
<button
type="button"
className="btn-force-field__btn"
onClick={(e) => {
const r = root.current!.getBoundingClientRect();
// Keyboard clicks have no pointer position: ripple from the button's centre.
const x = e.detail ? e.clientX - r.left : r.width / 2;
const y = e.detail ? e.clientY - r.top : r.height / 2;
setHit((h) => ({ x, y, n: (h?.n ?? 0) + 1 }));
}}
>
Raise shield
</button>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnForceField(el) on the mounted element instead.
<div class="btn-force-field">
<svg class="btn-force-field__field" aria-hidden="true">
<defs>
<pattern id="_R_0_-f" width="20.78" height="36" patternUnits="userSpaceOnUse" x="-0.5" y="3">
<path d="M10.39 0L20.78 6V18L10.39 24L0 18V6Z M10.39 24V36"></path>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#_R_0_-f)"></rect>
</svg>
<button type="button" class="btn-force-field__btn">Raise shield</button>
</div>@property --btn-force-field-r { syntax: '<length>'; inherits: false; initial-value: 0px; }
@keyframes btn-force-field-ripple {
from { --btn-force-field-r: 0px; opacity: 1; }
to { --btn-force-field-r: 240px; opacity: 0; }
}
.btn-force-field {
position: relative;
display: grid;
place-items: center;
width: 280px;
height: 150px;
}
.btn-force-field__field, .btn-force-field__ripple {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.btn-force-field__field {
opacity: 0;
mask-image: radial-gradient(ellipse closest-side, #000 35%, transparent);
transition: opacity 0.4s ease;
}
.btn-force-field__field path { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.btn-force-field__ripple path { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 8%, transparent); stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.2; }
.btn-force-field__ripple {
opacity: 0;
mask-image: radial-gradient(circle at var(--x) var(--y), transparent calc(var(--btn-force-field-r) - 30px), #000 calc(var(--btn-force-field-r) - 8px), transparent var(--btn-force-field-r));
}
.btn-force-field__ripple { animation: btn-force-field-ripple 0.9s cubic-bezier(0.22, 1, 0.36, 1); }
.btn-force-field:hover .btn-force-field__field,
.btn-force-field:focus-within .btn-force-field__field { opacity: 1; }
.btn-force-field__btn {
position: relative;
padding: 0.8rem 1.9rem;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.btn-force-field:hover .btn-force-field__btn { border-color: light-dark(#5f626a, #a7abb2); box-shadow: 0 0 20px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent); }
.btn-force-field__btn:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.btn-force-field__ripple { animation: none; }
}var btnForceFieldCount = 0;
function initBtnForceField(root) {
var field = root.querySelector('.btn-force-field__field');
var btn = root.querySelector('.btn-force-field__btn');
if (!field || !btn) return;
// Pattern ids must be unique per copy on the page.
var uid = 'btn-force-field-' + ++btnForceFieldCount;
field.querySelector('pattern').id = uid + '-f';
field.querySelector('rect').setAttribute('fill', 'url(#' + uid + '-f)');
var n = 0;
function ripple(e) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var r = root.getBoundingClientRect();
// Keyboard clicks have no pointer position: ripple from the button's centre.
var x = e.detail ? e.clientX - r.left : r.width / 2;
var y = e.detail ? e.clientY - r.top : r.height / 2;
var old = root.querySelector('.btn-force-field__ripple');
if (old) old.remove();
var svg = field.cloneNode(true);
var id = uid + '-r' + ++n;
svg.setAttribute('class', 'btn-force-field__ripple');
svg.querySelector('pattern').id = id;
svg.querySelector('rect').setAttribute('fill', 'url(#' + id + ')');
svg.style.setProperty('--x', x + 'px');
svg.style.setProperty('--y', y + 'px');
svg.addEventListener('animationend', function () { svg.remove(); });
root.insertBefore(svg, btn);
}
btn.addEventListener('click', ripple);
return function () { btn.removeEventListener('click', ripple); };
}
document.querySelectorAll('.btn-force-field').forEach(initBtnForceField);Goal: Create a "Force Field" button.
Visual behavior: Hover raises a honeycomb shield around the button. A click sends a ripple through the hex cells from the exact point you clicked (the centre, from the keyboard), lighting each ring of cells as it passes.
Technique:
- The shield is an SVG rectangle filled with a honeycomb <pattern> (one hexagon plus the stem to the next row tiles the plane), faded out at the edges with an elliptical radial mask; it fades in on hover or focus-within.
- A click mounts a second honeycomb whose cells are tinted and stroked brighter, masked to a ring: a radial-gradient mask centred on the click whose radius is a registered @property length.
- Animating that length from 0 to 240px (and opacity to 0) moves the bright ring outward through the cells.
- Keyboard clicks have no pointer position (event.detail is 0), so the ripple starts from the centre.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The shield appears on focus too, and keyboard activation ripples from the centre. Under prefers-reduced-motion there is no ripple.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Force Field" button.
Visual behavior: Hover raises a honeycomb shield around the button. A click sends a ripple through the hex cells from the exact point you clicked (the centre, from the keyboard), lighting each ring of cells as it passes.
Technique:
- The shield is an SVG rectangle filled with a honeycomb <pattern> (one hexagon plus the stem to the next row tiles the plane), faded out at the edges with an elliptical radial mask; it fades in on hover or focus-within.
- A click mounts a second honeycomb whose cells are tinted and stroked brighter, masked to a ring: a radial-gradient mask centred on the click whose radius is a registered @property length.
- Animating that length from 0 to 240px (and opacity to 0) moves the bright ring outward through the cells.
- Keyboard clicks have no pointer position (event.detail is 0), so the ripple starts from the centre.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The shield appears on focus too, and keyboard activation ripples from the centre. Under prefers-reduced-motion there is no ripple.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Hover raises a honeycomb shield around the button. A click sends a ripple through the hex cells from the exact point you clicked (the centre, from the keyboard), lighting each ring of cells as it passes.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, click, glow
- Techniques
- svg, css property, keyframes, radial gradient, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. The shield appears on focus too, and keyboard activation ripples from the centre. Under prefers-reduced-motion there is no ripple.