JellyButton
// preview
Hover scale
Squish
Stiffness
Damping
Magnet radius (px)
Magnet
// source
TSJS
TailwindCSS
/*!
* JellyButton, a DesignPass.dev component by Ernest Liu
* Docs & live playground: https://designpass.dev/components/jelly-button
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, {
useCallback,
useEffect,
useRef,
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import Magnet from "./Magnet";
export interface JellyButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
/** Scale the button grows to on hover (bouncy spring, slight overshoot). */
hoverScale?: number;
/** How much the button squishes while pressed: 0.08 means 8% wider and
* 12% flatter, like jelly under a thumb. */
squish?: number;
/** Spring stiffness for hover/press motion. Higher snaps faster. */
stiffness?: number;
/** Spring damping. Lower is wobblier. */
damping?: number;
/** Let the button lean toward a nearby cursor (via Magnet). */
magnet?: boolean;
/** Extra distance (px) around the button where the magnetic pull begins. */
magnetPadding?: number;
wrapperClassName?: string;
}
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
// Bouncy spring shared by hover growth and press squish, so releasing a
// press wobbles back like jelly instead of easing home.
const DEFAULT_STIFFNESS = 0.22;
const DEFAULT_DAMPING = 0.62;
export default function JellyButton({
children,
hoverScale = 1.05,
squish = 0.08,
stiffness = DEFAULT_STIFFNESS,
damping = DEFAULT_DAMPING,
magnet = true,
magnetPadding = 24,
wrapperClassName = "",
className = "",
disabled,
...props
}: JellyButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
// Spring state lives outside React, no re-render per frame.
const physics = useRef({
scale: 1,
scaleVelocity: 0,
scaleTarget: 1,
squish: 0,
squishVelocity: 0,
squishTarget: 0,
hovering: false,
pressing: false,
frame: 0,
running: false,
reducedMotion: false,
});
const render = useCallback(() => {
const button = buttonRef.current;
if (!button) return;
const state = physics.current;
// Squish preserves visual volume: wider exactly as much as it flattens.
const sx = state.scale * (1 + state.squish);
const sy = state.scale * (1 - state.squish);
button.style.transform = `scale(${sx}, ${sy})`;
}, []);
const wake = useCallback(() => {
const state = physics.current;
if (state.reducedMotion) {
state.scale = state.scaleTarget;
state.squish = state.squishTarget;
render();
return;
}
if (state.running) return;
state.running = true;
const tick = () => {
state.scaleVelocity =
(state.scaleVelocity + (state.scaleTarget - state.scale) * stiffness) * damping;
state.scale += state.scaleVelocity;
state.squishVelocity =
(state.squishVelocity + (state.squishTarget - state.squish) * stiffness) * damping;
state.squish = clamp(state.squish + state.squishVelocity, -0.4, 0.4);
render();
const settled =
Math.abs(state.scaleVelocity) < 0.0005 &&
Math.abs(state.scaleTarget - state.scale) < 0.0005 &&
Math.abs(state.squishVelocity) < 0.0005 &&
Math.abs(state.squishTarget - state.squish) < 0.0005;
if (settled) {
state.scale = state.scaleTarget;
state.squish = state.squishTarget;
render();
state.running = false;
return;
}
state.frame = requestAnimationFrame(tick);
};
state.frame = requestAnimationFrame(tick);
}, [render, stiffness, damping]);
useEffect(() => {
const state = physics.current;
state.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return () => {
cancelAnimationFrame(state.frame);
state.running = false;
};
}, []);
const retarget = useCallback(() => {
const state = physics.current;
state.scaleTarget = state.hovering && !disabled ? hoverScale : 1;
state.squishTarget = state.pressing && !disabled ? squish : 0;
wake();
}, [disabled, hoverScale, squish, wake]);
function setHovering(next: boolean) {
physics.current.hovering = next;
if (!next) physics.current.pressing = false;
retarget();
}
function setPressing(next: boolean) {
physics.current.pressing = next;
retarget();
}
const button = (
<button
ref={buttonRef}
disabled={disabled}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
onPointerDown={() => setPressing(true)}
onPointerUp={() => setPressing(false)}
onPointerCancel={() => setPressing(false)}
// Keyboard activation squishes too, so Enter/Space feel the same.
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setPressing(true);
}}
onKeyUp={() => setPressing(false)}
onBlur={() => setPressing(false)}
style={
{
// Follows host theme tokens when present; standalone fallback is
// a purple pill with white text.
"--jb-bg": "var(--dp-accent, #a05cff)",
"--jb-ink": "var(--dp-accent-contrast, #fff)",
} as React.CSSProperties
}
className={`inline-flex cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-full bg-[var(--jb-bg)] px-5 py-2.5 text-sm font-semibold text-[var(--jb-ink)] outline-none will-change-transform focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--jb-bg)_55%,transparent)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
{...props}
>
{children}
</button>
);
if (!magnet || disabled) {
return <span className={`inline-block ${wrapperClassName}`}>{button}</span>;
}
return (
<Magnet
padding={magnetPadding}
magnetStrength={6}
tiltStrength={0}
glare={false}
lift={1}
wrapperClassName={`inline-block ${wrapperClassName}`}
>
{button}
</Magnet>
);
}
// install
Install the JellyButton component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/JellyButton-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.Need the license details? Read the component license.