{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magnetic","type":"registry:ui","title":"Magnetic","description":"A generic wrapper that pulls its child toward the pointer once it enters an activation field larger than the child's own box, and springs back to rest the instant the pointer leaves — a fine-pointer affordance with no touch or keyboard equivalent","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["effects"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/magnetic/Magnetic.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { observeInView } from \"../../internals/motion/in-view\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { rafThrottle } from \"../../internals/motion/raf\";\nimport \"./magnetic.css\";\n\n/**\n * Props for Magnetic — a generic single-child wrapper that translates its\n * child toward the pointer while the pointer sits inside an activation\n * field larger than the child's own box, and springs back to rest once\n * the pointer leaves. The field is the child's bounding rect inflated by\n * `radius` on every side (not a circular distance-from-center test) so a\n * wide button and a small icon feel equally \"magnetic\" at their edges.\n *\n * There is deliberately no keyboard/focus equivalent: unlike Reveal\n * (which reveals on `focusin`), Magnetic never *reacts* to focus —\n * `focusin` triggers nothing, and Tab never starts, stops, or changes a\n * pull. That is not the same claim as \"focus always finds it at rest\":\n * if a fine pointer happens to be resting inside the field when focus\n * arrives, the child is already displaced by that pointer, and Tab lands\n * on it there — the resting position is the pointer's, not the layout's.\n * Pointer attraction is a fine-pointer affordance, not a state to\n * replicate for every input method. See `README.md` for the full\n * reasoning.\n */\nexport interface MagneticProps extends HTMLAttributes<HTMLDivElement> {\n\t/** Additional CSS classes, merged onto the static outer wrapper. */\n\tclassName?: string;\n\t/** Pull multiplier applied to the pointer's offset from the child's center. Higher = a more aggressive follow. */\n\tstrength?: number;\n\t/** Pixels the activation field extends beyond the outer element's own box, on every side. Also sizes the (transparent by default) `::before` halo. */\n\tradius?: number;\n\t/** Per-axis clamp (px) on the translated offset — a hard travel cap independent of element geometry, `radius`, or `strength`. */\n\tmax?: number;\n\t/** Disables the pull: no listeners are attached at all, and both translation vars are pinned at `0px`. Never touches the wrapped child's own `disabled` state. */\n\tdisabled?: boolean;\n\t/** The single wrapped element — a button, icon link, or card. */\n\tchildren: ReactNode;\n}\n\n// Literal defaults, also the \"has this prop actually been customized\"\n// baseline for the inline `--ft-magnetic-radius` var below (see the\n// common contract's \"per-component var written inline only when the\n// prop is provided\" rule) — an instance left at the default must not\n// shadow a page-level `--ft-magnetic-radius` override with its own\n// hardcoded copy of the same number.\nconst DEFAULT_STRENGTH = 0.35;\nconst DEFAULT_RADIUS = 40;\nconst DEFAULT_MAX = 24;\n\nfunction clamp(value: number, bound: number): number {\n\treturn Math.min(bound, Math.max(-bound, value));\n}\n\nexport const Magnetic = forwardRef<HTMLDivElement, MagneticProps>(function Magnetic(\n\t{\n\t\tclassName,\n\t\tstrength = DEFAULT_STRENGTH,\n\t\tradius = DEFAULT_RADIUS,\n\t\tmax = DEFAULT_MAX,\n\t\tdisabled = false,\n\t\tchildren,\n\t\tstyle,\n\t\t...restProps\n\t},\n\tforwardedRef\n) {\n\t// The outer node is the stable reference frame every frame's\n\t// `getBoundingClientRect()` reads against; the inner node is the ONLY\n\t// node that ever receives `transform` — if the halo or the activation\n\t// math lived on a node that itself moved, the field would desync from\n\t// the pull and visually \"chase\" the pointer.\n\tconst [outer, outerRefCallback] = useElementRef<HTMLDivElement>();\n\tconst [inner, innerRefCallback] = useElementRef<HTMLDivElement>();\n\tconst composedOuterRef = useComposedRefs(forwardedRef, outerRefCallback);\n\n\tconst [magnetState, setMagnetState] = useState<\"idle\" | \"active\">(\"idle\");\n\t// Tracks whether the field is actually holding a non-rest pull that a\n\t// future `release()` would need to zero out. Deliberately NOT the same\n\t// thing as `magnetState === \"active\"`: right after mount the state is\n\t// already \"idle\" but the vars have never been written at all (still the\n\t// empty string, not the literal \"0px\"), so the very first out-of-field\n\t// frame must still be allowed through once. Starts `true` for exactly\n\t// that reason; `release()` clears it, the active branch below re-arms it.\n\tconst needsRelease = useRef(true);\n\n\tconst reduced = useReducedMotion();\n\t// Read live by the frame handler so a mid-interaction prop change is\n\t// honored on the very next frame without rebuilding the listener stack —\n\t// the counterpart of the source component's live prop reads.\n\tconst strengthRef = useLiveRef(strength);\n\tconst maxRef = useLiveRef(max);\n\n\t// The entire pointer-tracking stack — IntersectionObserver, the window\n\t// `pointermove` listener, the document `pointerleave` safety net — exists\n\t// only while Magnetic is actually capable of moving anything. Disabled\n\t// and reduced-motion both gate at this single point (rather than being\n\t// threaded through each listener separately) so \"no listeners attached\"\n\t// is one branch to verify, not three, and so flipping either prop\n\t// mid-interaction tears everything down through the same cleanup path\n\t// an unmount would use.\n\tuseEffect(() => {\n\t\tif (!outer || !inner) return;\n\n\t\t/**\n\t\t * Zeroes the translation and flips back to idle. One function for every\n\t\t * release path — pointer left the inflated rect, the pointer left the\n\t\t * whole document, a listener teardown, `disabled`/reduced-motion turning\n\t\t * on mid-interaction — so none of them can disagree about what\n\t\t * \"released\" actually means. Vars are written as the literal `0px`\n\t\t * (never removed), matching the CSS's own `0px` fallback exactly.\n\t\t */\n\t\tfunction release() {\n\t\t\tsetMagnetState(\"idle\");\n\t\t\tinner?.style.setProperty(\"--ft-magnetic-x\", \"0px\");\n\t\t\tinner?.style.setProperty(\"--ft-magnetic-y\", \"0px\");\n\t\t\tneedsRelease.current = false;\n\t\t}\n\n\t\tif (disabled || reduced) {\n\t\t\trelease();\n\t\t\treturn;\n\t\t}\n\n\t\tconst handlePointerMove = rafThrottle((event: PointerEvent) => {\n\t\t\t// A touch pointer resting on the field (or a second/third simultaneous\n\t\t\t// touch point) is not \"hovering near\" anything — it is either about to\n\t\t\t// tap or already part of a multi-touch gesture neither of which this\n\t\t\t// affordance should react to.\n\t\t\tif (event.pointerType === \"touch\" || !event.isPrimary) return;\n\n\t\t\t// Re-measured every frame rather than cached at listener-attach time:\n\t\t\t// a layout shift under the pointer (reflow, a sibling resizing) must\n\t\t\t// never leave the field testing a stale rect.\n\t\t\tconst rect = outer.getBoundingClientRect();\n\t\t\tconst { clientX: px, clientY: py } = event;\n\t\t\tconst inField =\n\t\t\t\tpx >= rect.left - radius &&\n\t\t\t\tpx <= rect.right + radius &&\n\t\t\t\tpy >= rect.top - radius &&\n\t\t\t\tpy <= rect.bottom + radius;\n\n\t\t\tif (!inField) {\n\t\t\t\t// Only pay for the write path (a forced `getBoundingClientRect()` is\n\t\t\t\t// already unavoidable above, but `release()` also does two CSSOM\n\t\t\t\t// `style.setProperty()` calls) when there is actually something to\n\t\t\t\t// release — otherwise every `pointermove` anywhere on the page, for\n\t\t\t\t// the entire time an instance is on screen, dirties this element's\n\t\t\t\t// style every frame for no reason.\n\t\t\t\tif (needsRelease.current) release();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst centerX = rect.left + rect.width / 2;\n\t\t\tconst centerY = rect.top + rect.height / 2;\n\t\t\tconst pullX = clamp((px - centerX) * strengthRef.current, maxRef.current);\n\t\t\tconst pullY = clamp((py - centerY) * strengthRef.current, maxRef.current);\n\n\t\t\tsetMagnetState(\"active\");\n\t\t\tneedsRelease.current = true;\n\t\t\tinner.style.setProperty(\"--ft-magnetic-x\", `${pullX}px`);\n\t\t\tinner.style.setProperty(\"--ft-magnetic-y\", `${pullY}px`);\n\t\t});\n\n\t\tlet windowListenerAttached = false;\n\n\t\tfunction attach() {\n\t\t\tif (windowListenerAttached) return;\n\t\t\twindowListenerAttached = true;\n\t\t\twindow.addEventListener(\"pointermove\", handlePointerMove, { passive: true });\n\t\t}\n\t\tfunction detach() {\n\t\t\tif (!windowListenerAttached) return;\n\t\t\twindowListenerAttached = false;\n\t\t\twindow.removeEventListener(\"pointermove\", handlePointerMove);\n\t\t\trelease();\n\t\t}\n\n\t\t// The framework-free core is called directly (not through `useInView`):\n\t\t// the observer must not exist AT ALL while disabled or reduced-motion\n\t\t// (this effect's own guard above), and the hook has no way to skip\n\t\t// construction without also giving up the attach/detach pairing below.\n\t\t//\n\t\t// `rootMargin` is inflated by `radius` (not left at the foundation's\n\t\t// own default) so a Magnetic instance sitting just outside the\n\t\t// viewport — but whose activation field already pokes into it — still\n\t\t// gets its window listener attached; a plain \"is the raw box on\n\t\t// screen\" test would otherwise miss that edge case entirely.\n\t\tconst ioHandle = observeInView(outer, {\n\t\t\tonce: false,\n\t\t\tthreshold: 0,\n\t\t\trootMargin: `${radius}px`,\n\t\t\tonChange: (intersecting) => {\n\t\t\t\tif (intersecting) attach();\n\t\t\t\telse detach();\n\t\t\t},\n\t\t});\n\n\t\t// A pointer dragged off to OS chrome or another window never fires\n\t\t// another `pointermove` inside this page — without this, the last\n\t\t// computed pull would freeze on screen instead of releasing.\n\t\t// `pointerleave` (not `mouseleave`) is the event that actually fires\n\t\t// when the pointer leaves the whole document, not just one element.\n\t\tdocument.addEventListener(\"pointerleave\", release, { passive: true });\n\n\t\t// `pointerleave` alone misses a keyboard tab switch (Cmd/Alt+Tab,\n\t\t// Ctrl+Tab): no pointer crosses any boundary, so nothing above fires,\n\t\t// and the pull would otherwise freeze mid-travel until the pointer\n\t\t// moves again on return. `window`'s `blur` fires whenever the page\n\t\t// loses focus for any reason — including, but not limited to, a tab\n\t\t// switch — so it releases that case too.\n\t\twindow.addEventListener(\"blur\", release, { passive: true });\n\n\t\treturn () => {\n\t\t\tioHandle.destroy();\n\t\t\tdetach();\n\t\t\tdocument.removeEventListener(\"pointerleave\", release);\n\t\t\twindow.removeEventListener(\"blur\", release);\n\t\t\thandlePointerMove.cancel();\n\t\t};\n\t\t// `strength`/`max` are read live through refs on every frame and must\n\t\t// not tear the listener stack down; `radius` changes the observer's\n\t\t// own rootMargin, so it rebuilds — matching the source's reactivity.\n\t}, [outer, inner, disabled, reduced, radius, strengthRef, maxRef]);\n\n\tconst mergedStyle =\n\t\tradius === DEFAULT_RADIUS\n\t\t\t? style\n\t\t\t: ({ ...style, \"--ft-magnetic-radius\": `${radius}px` } as CSSProperties);\n\n\treturn (\n\t\t<div\n\t\t\tref={composedOuterRef}\n\t\t\tclassName={cn(\"ft-magnetic\", className)}\n\t\t\t{...restProps}\n\t\t\tstyle={mergedStyle}\n\t\t\tdata-state={magnetState}\n\t\t\tdata-disabled={disabled ? \"true\" : undefined}\n\t\t>\n\t\t\t<div ref={innerRefCallback} className=\"ft-magnetic-inner\">\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/magnetic/Magnetic.tsx"},{"path":"react/src/components/magnetic/index.ts","content":"\"use client\";\n\nexport { Magnetic } from \"./Magnetic\";\nexport type { MagneticProps } from \"./Magnetic\";\n","type":"registry:ui","target":"@ui/fancy/components/magnetic/index.ts"},{"path":"react/src/components/magnetic/magnetic.css","content":"/*\n * `inline-block` on both nodes: a block-level wrapper would force a line\n * break around an inline child (an icon link sitting mid-sentence), but\n * the inner node still needs a real box for `transform` to apply to at\n * all — `display: contents` (no box) would make the translation a no-op.\n */\n.ft-magnetic {\n\tposition: relative;\n\tdisplay: inline-block;\n}\n\n/*\n * The activation field's visual footprint, sized to match the geometry\n * `radius` actually drives. Fully inert by construction: `pointer-events:\n * none` means no real hit-testable box is ever enlarged, so a generous\n * `radius` can never start stealing clicks or hovers from a neighbour —\n * only the halo's own (default: invisible) paint grows. Lives on the\n * OUTER (never-transformed) node so it never drifts out of sync with the\n * activation math, which is always measured against the outer's own\n * untransformed rect.\n */\n.ft-magnetic::before {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: calc(\n\t\t-1 * var(--ft-magnetic-radius, 40px)\n\t); /* tokens: Magnetic's own default radius (40) — no tokens.ts entry, geometry is per-component */\n\tpointer-events: none;\n\t/* No `background` declared here (not even `transparent`, which is the\n\t   property's own initial value and so changes nothing by omitting it):\n\t   leaving the property undeclared means a consumer's own\n\t   `.ft-magnetic::before` rule is the ONLY one setting it, which is what\n\t   makes README's halo recipe (and the contract's promise of it)\n\t   actually work without a specificity fight. */\n\tborder-radius: inherit;\n}\n\n.ft-magnetic-inner {\n\t/* `position: relative` unconditionally (not just under the transform\n\t   media queries below): `::before` is `position: absolute`, so per\n\t   CSS 2.1 §\"painting order\" it is ALWAYS painted in the positioned\n\t   layer. Without this, `.ft-magnetic-inner` is only in that same layer\n\t   while its `transform` creates a stacking context — true under\n\t   `no-preference` + `hover: hover`, false under reduced motion and\n\t   under `(hover: none)` (`transform: none`) — so the halo would paint\n\t   OVER the child in exactly those two states. Making the inner node\n\t   positioned too keeps both boxes in the positioned layer, painted in\n\t   tree order (halo, an earlier sibling, first) in all four\n\t   combinations, regardless of whether a transform happens to be live. */\n\tposition: relative;\n\tdisplay: inline-block;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-magnetic-inner {\n\t\ttransform: translate(var(--ft-magnetic-x, 0px), var(--ft-magnetic-y, 0px));\n\t}\n\n\t/* An eased curve, not `transition: none`: the target is rewritten every\n\t   frame, so this smoothing reads as slight inertia (a hand-held jitter\n\t   does not become a control jitter) rather than a delay — replaying\n\t   raw rAF-sampled pointer motion one-to-one would twitch, not follow. */\n\t.ft-magnetic[data-state=\"active\"] .ft-magnetic-inner {\n\t\ttransition: transform var(--ft-duration-fast, 150ms)\n\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1)); /* tokens.DURATIONS.fast / tokens.EASINGS.inout */\n\t\twill-change: transform;\n\t}\n\n\t/* Slower on release: the element settles back to rest rather than\n\t   snapping off, and reads as \"let go\" instead of \"cut short\". */\n\t.ft-magnetic[data-state=\"idle\"] .ft-magnetic-inner {\n\t\ttransition: transform var(--ft-duration-base, 300ms)\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); /* tokens.DURATIONS.base / tokens.EASINGS.out */\n\t}\n}\n\n/* Coarse pointers (touchscreens without a mouse) never see a pull at\n   all, in addition to the `pointerType === \"touch\"` guard in JS above —\n   defense in depth, not redundant: a stylus/pen pointer type is not\n   ignored by the JS guard, but a touch-only device still has no hover\n   capability to drive a meaningful follow. */\n@media (hover: none) {\n\t.ft-magnetic-inner {\n\t\ttransform: none;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/magnetic/magnetic.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/magnetic?framework=react"}}