Skip to content
Effects Fancy Stable

MagneticField

.md

Items are pulled toward a magnet and lean toward it, light ones more than heavy ones, then spring home. The magnet is the pointer or a draggable puck.

Available for Svelte 5, React, and Vue 3.

Preview

Installation

pnpm add fancy-ui-svelte
import { MagneticField } from "fancy-ui-svelte";

Usage

svelte
<script lang="ts">
	import {
		MagneticField,
		MagneticItem,
		MagnetPuck,
		type MagneticPoint,
	} from "fancy-ui-svelte";
	import Copy from "@lucide/svelte/icons/copy";
	import Magnet from "@lucide/svelte/icons/magnet";

	// Light things jump, heavy things barely move: the chips and the icon are the
	// lightest, the paragraph the heaviest block on the card.
	const chips = [
		{ label: "Springs", mass: 0.6 },
		{ label: "Falloff", mass: 0.75 },
		{ label: "Polarity", mass: 0.7 },
		{ label: "Mass", mass: 0.85, current: true },
	];

	// The puck's tray, top left of the field. A released puck glides back to
	// it while the items spring home.
	const HOME: MagneticPoint = { x: 44, y: 40 };
	let position = $state<MagneticPoint>({ ...HOME });

	// Amplitude follows the field's width, so a narrow screen is not one heap
	// of items under the magnet: 32 px of travel on a wide card, down to 16.
	let width = $state(0);
	const maxTranslate = $derived(
		width > 0 ? Math.round(Math.min(32, Math.max(16, width * 0.05))) : 32
	);
	const radius = $derived(width > 0 && width < 480 ? 200 : 260);
</script>

<!-- The puck sits in the tray above the card. Pick it up and bring it close,
     or simply move a mouse near the card. -->
<div bind:clientWidth={width} class="mx-auto w-full max-w-2xl">
	<MagneticField
		{radius}
		{maxTranslate}
		maxRotate={6}
		class="bg-muted/60 dark:bg-muted/30 w-full rounded-2xl px-3 pt-20 pb-10 sm:px-16"
	>
		<p class="text-muted-foreground absolute top-7 left-20 pr-4 text-xs leading-snug">
			Drag the magnet toward the card, or bring your pointer close.
		</p>

		<article
			class="mx-auto w-full max-w-[400px] rounded-[28px] bg-[#111112] p-6 text-[#f7f7f5] shadow-[0_24px_60px_-28px_rgb(0_0_0/0.6)] ring-1 ring-white/10 sm:p-10 dark:bg-[#19191b] dark:ring-white/15"
		>
			<header class="flex items-center justify-between">
				<MagneticItem mass={1}>
					<span class="flex items-center gap-2 text-[15px] font-semibold tracking-tight">
						<span class="size-3 rounded-full bg-[#f7f7f5]"></span>
						Lodestone
					</span>
				</MagneticItem>
				<MagneticItem mass={0.7}>
					<span class="rounded-full bg-[#1d2442] px-3 py-1 text-xs font-medium text-[#9db0ff]">
						Field kit
					</span>
				</MagneticItem>
			</header>

			<div class="mt-8 flex flex-wrap gap-2">
				{#each chips as chip (chip.label)}
					<MagneticItem inline mass={chip.mass}>
						<span
							class="rounded-full px-3.5 py-1.5 text-[13px] font-medium {chip.current
								? 'bg-[#41463c] text-white'
								: 'bg-[#252528] text-[#d9d9d6]'}"
						>
							{chip.label}
						</span>
					</MagneticItem>
				{/each}
			</div>

			<MagneticItem mass={0.6} class="mt-8 w-fit">
				<span
					class="flex size-11 items-center justify-center rounded-[12px] bg-[#38363a] text-[#c9c9c4]"
				>
					<Magnet class="size-5" aria-hidden="true" />
				</span>
			</MagneticItem>

			<MagneticItem mass={1.2} class="mt-4 w-fit">
				<p class="text-[30px] leading-[1.05] font-bold tracking-tight text-balance sm:text-[34px]">
					Pull, then let go
				</p>
			</MagneticItem>

			<MagneticItem mass={2.5}>
				<p class="mt-4 text-[13.5px] leading-5 text-[#b9b9b4]">
					Every block on this card has its own weight. Bring the magnet close and the light ones
					jump first, the heading follows, and this paragraph, the heaviest of all, barely moves
					before everything settles back home.
				</p>
			</MagneticItem>

			<div class="mt-8 flex flex-wrap items-center justify-between gap-3">
				<MagneticItem mass={1}>
					<button
						type="button"
						class="rounded-full bg-[#3c3936] px-3.5 py-2 text-[13px] font-medium whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#9db0ff] sm:px-4"
					>
						Preview
					</button>
				</MagneticItem>
				<MagneticItem mass={1.1}>
					<button
						type="button"
						class="flex items-center gap-2 rounded-full bg-[#232120] px-3.5 py-2 text-[13px] font-medium whitespace-nowrap ring-1 ring-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#9db0ff] sm:px-4"
					>
						<Copy class="size-3.5" aria-hidden="true" />
						Copy install
					</button>
				</MagneticItem>
			</div>
		</article>

		<MagnetPuck
			bind:position
			size={48}
			label="Magnet: drag it toward the card"
			onHeldChange={(held) => {
				if (!held) position = { ...HOME };
			}}
			class="motion-safe:[&:not([data-held])]:transition-transform motion-safe:[&:not([data-held])]:duration-500 motion-safe:[&:not([data-held])]:ease-out"
		/>
	</MagneticField>
</div>

Examples

Basic Usage

A product card whose pieces lean and drift toward a magnet. Drag the puck close, or bring a mouse near the card.

Pointer Field

A cloud of tags that follows the pointer, and a status line that shows when the field is awake.

Repel

`polarity={-1}` with a sticky puck: drag it through the grid and the tiles part around it. Works on touch.

Interactive Playground

Props

PropTypeDefaultDescription
source "pointer" | "manual""pointer"Where the magnet comes from. `"pointer"` follows a mouse or pen anywhere within `radius` of an item (touch is ignored); `"manual"` reads `position` instead. A `MagnetPuck` placed inside the field drives it in either mode while it is held.
position MagneticPoint | nullnullThe magnet's position in px, relative to the field's padding box, used when `source` is `"manual"`. `null` means no magnet, and the items spring back to rest.
radius number220Falloff distance from the item's nearest edge, in px: an item pulls hardest when the magnet touches or overlaps it and stops moving once the magnet is `radius` px away. It can still lean up to twice as far.
strength number1Global pull multiplier, applied on top of each item's own `strength`. `0` turns the effect off without detaching anything.
maxTranslate number24Largest distance, in px, that any item travels toward the magnet.
maxRotate number6Largest lean, in degrees. Items lean toward the magnet's side like a crowd turning its head: clockwise when it is to their left, counter-clockwise when it is to their right.
spring MagneticSpringPreset | Partial<MagneticSpring>"default"How items move and settle: `"soft"`, `"default"` or `"snappy"`, or your own `{ stiffness, damping, mass }` (missing keys come from `"default"`).
polarity 1 | -11`1` attracts and `-1` repels. Only translation flips; the lean stays the same.
disabled booleanfalseDetaches every listener and springs each item back to rest.
onActiveChange (active: boolean) => void-Called when the field wakes up (a magnet is engaged or an item is still settling) and when it goes back to sleep.
class string-Additional CSS classes.
ref HTMLDivElement | nullnullElement reference (bindable).
MagneticItem.mass number1How heavy the item is. Above 1 it moves and leans less and settles more slowly; below 1 it is livelier.
MagneticItem.strength number1Per-item pull multiplier, applied on top of the field's `strength`.
MagneticItem.pull booleantrueWhether the item moves toward the magnet.
MagneticItem.rotate booleantrueWhether the item leans toward the magnet.
MagneticItem.inline booleanfalseRenders `<span>` wrappers with `display: inline-block` instead of block `<div>`s, for items that sit inside a line of text or an inline row.
MagneticItem.class string-Additional CSS classes, on the static outer wrapper.
MagneticItem.ref HTMLElement | nullnullReference to the static outer wrapper (bindable).
MagnetPuck.position MagneticPoint{ x: 48, y: 48 }The puck's centre in px, relative to its positioned parent (the field's padding box when it sits inside a `MagneticField`, where the field must be that parent: no positioned element in between). Bindable; dragging and the arrow keys update it.
MagnetPuck.onPositionChange (position: MagneticPoint) => void-Called with each new position while the puck is dragged or moved with the keyboard.
MagnetPuck.size number40Diameter in px.
MagnetPuck.sticky booleanfalseWhether the puck keeps attracting once released. By default it attracts only while held, and the items spring back when it is let go.
MagnetPuck.label string"Magnet"Accessible name. A built-in hint naming the keys describes the puck; pass your own `aria-describedby` to replace it.
MagnetPuck.onHeldChange (held: boolean) => void-Called when the puck is picked up (pointer down, or Space/Enter) and put down.
MagnetPuck.class string-Additional CSS classes.
MagnetPuck.ref HTMLDivElement | nullnullElement reference (bindable).

Slots

SlotDescription
children The content. Wrap each element that should react in a `MagneticItem`.
MagneticItem.children The element that reacts.
MagnetPuck.children A custom face. Leave it out for the default black disc.