Magnetic
Wrapper that pulls its content toward the pointer for a magnetic effect. Install Magnetic from the VLLNT UI registry with the shadcn CLI.
Wrapper that pulls its content toward the pointer for a magnetic effect. Part of the utility family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Magnetic to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/magnetic.jsonSource
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
/** Props for {@link Magnetic}. */
export type MagneticProps = React.ComponentPropsWithoutRef<"div"> & {
/** Fraction of the pointer offset applied as pull. Defaults to `0.4`. */
strength?: number;
};
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return;
}
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (): void => {
setReduced(query.matches);
};
onChange();
query.addEventListener("change", onChange);
return () => {
query.removeEventListener("change", onChange);
};
}, []);
return reduced;
}
/**
* Wrapper that drifts its content toward the pointer, then snaps back.
*
* Respects `prefers-reduced-motion`: the content stays put.
*
* @example
* ```tsx
* <Magnetic><img src="/logo.svg" alt="logo" /></Magnetic>
* ```
*/
export const Magnetic = ({
children,
className,
ref,
strength = 0.4,
style,
...props
}: MagneticProps & { ref?: React.Ref<HTMLDivElement> }) => {
const reduced = usePrefersReducedMotion();
const [transform, setTransform] = React.useState<string>();
const handlePointerMove = (
event: React.PointerEvent<HTMLDivElement>,
): void => {
if (reduced) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
const offsetX = (event.clientX - bounds.left - bounds.width / 2) * strength;
const offsetY = (event.clientY - bounds.top - bounds.height / 2) * strength;
setTransform(`translate(${offsetX}px, ${offsetY}px)`);
};
return (
<div
className={cn(
"transition-transform duration-200 ease-out will-change-transform",
className,
)}
onPointerLeave={() => {
setTransform(undefined);
}}
onPointerMove={handlePointerMove}
ref={ref}
style={{ transform, ...style }}
{...props}
>
{children}
</div>
);
};
Magnetic.displayName = "Magnetic";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.