Magnetic Select

beta

A honeycomb of round chips: choosing one grows it and magnetically shoves the rest apart, tilted, with a springy settle.

Installation

Terminal
pnpm dlx @dowel-ui/cli add magnetic-select

npm packages installed: class-variance-authority.

Accessibility

A role="radiogroup" of native buttons with role="radio" and aria-checked, named by each option's `label`; the group needs aria-label or aria-labelledby and warns in development without one. Roving tabindex: Tab reaches the selected chip (or the first), arrow keys move focus and select with wrap-around (Left/Right follow the reading direction in RTL), Home/End jump to the ends. Images and skins are decorative. The spring is a CSS transition, so under reduced motion every chip settles at its final pose immediately; cursor parallax is mouse-only.

Props

MagneticSelect

PropTypeDefault
options (required)

Up to 19 options: a centre, a ring of six and a ring of twelve. Three form a triangle.

MagneticSelectOption[]
bounce

Overshoot, 0–100. 0 settles with no overshoot.

number55
chipSize

Chip diameter in px.

number44
disabled

Disables every chip.

booleanfalse
give

Spring softness, 0–100. Above 50 the chips take longer to settle.

number50
name

Renders a hidden input so the value submits with a form.

string
onValueChange

Called with the newly selected value.

(value: string) => void
pull

Magnet strength, 0–100: selected scale, push distance and tilt.

number55
strokebooleanfalse
value

Controlled selected value.

string

Plus every attribute of <div> except defaultValue, onChange, children, selected.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add magnetic-select writes into your project, with imports rewritten to your own path alias.

ui/magnetic-select.tsx
"use client";

// Ported from bencho Magnetic select (MIT, © 2026 Lorenzo Cabra). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import {
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import { springEasing } from "./magnetic-select-spring";

/*
 * A radiogroup of round chips packed in a honeycomb. Choosing one grows it and
 * shoves the rest radially away, a little smaller and tilted, like marbles
 * pushed apart by a magnet.
 *
 * The source drives this with a JavaScript spring. Nothing here carries
 * gesture velocity, so it is a CSS transition instead: the spring becomes a
 * `linear()` easing (see magnetic-select-spring.ts), durations run through
 * --motion-scale, and the reduced-motion blanket settles every chip at once.
 *
 * Geometry is laid out from the inline start, so the honeycomb mirrors in RTL
 * on its own. Transforms are physical, so every horizontal offset and tilt is
 * multiplied by --magnetic-select-dir, which the `rtl:` variant flips to -1.
 */

const PREFIX = "dowel-magnetic-select";

const STYLES = `
@supports (transition-timing-function: linear(0, 1)) {
  [data-slot="magnetic-select-item"], [data-slot="magnetic-select-body"] {
    transition-timing-function: var(--magnetic-select-spring);
  }
}
`;

/** The skin: the visible disc inside each chip. */
const magneticSelectVariants = cva(
  cn(
    "relative block size-full overflow-hidden rounded-full bg-card",
    "translate-x-[var(--lx,0px)] translate-y-[var(--ly,0px)]",
    "transition-[translate,background-color] duration-[calc(260ms*var(--motion-scale))] ease-[var(--ease-out-quint)]",
  ),
  {
    variants: {
      /** An inset hairline ring on every skin — the workbench "Stroke" switch. */
      stroke: {
        true: "after:pointer-events-none after:absolute after:inset-0 after:rounded-full after:ring-1 after:ring-border after:content-[''] after:ring-inset",
        false: "",
      },
      selected: {
        true: "",
        false:
          "group-hover/chip:bg-[color-mix(in_oklab,var(--color-foreground)_11%,var(--color-card))]",
      },
    },
    defaultVariants: {
      stroke: false,
      selected: false,
    },
  },
);

export interface MagneticSelectOption {
  value: string;
  /** Accessible name of the chip. Chips have no visible text of their own. */
  label: string;
  /** Image shown on the chip while it is selected (object-cover). Decorative. */
  image?: string;
  /** Content drawn on the chip in every state — an icon, initials, a swatch. */
  content?: ReactNode;
}

export interface MagneticSelectProps
  extends
    Omit<ComponentPropsWithRef<"div">, "defaultValue" | "onChange" | "children">,
    Omit<VariantProps<typeof magneticSelectVariants>, "selected"> {
  /** Up to 19 options: a centre, a ring of six and a ring of twelve. Three form a triangle. */
  options: MagneticSelectOption[];
  /** Controlled selected value. */
  value?: string;
  /** Initial value when uncontrolled. */
  defaultValue?: string;
  /** Called with the newly selected value. */
  onValueChange?: (value: string) => void;
  /** Magnet strength, 0–100: selected scale, push distance and tilt. */
  pull?: number;
  /** Overshoot, 0–100. 0 settles with no overshoot. */
  bounce?: number;
  /** Spring softness, 0–100. Above 50 the chips take longer to settle. */
  give?: number;
  /** Chip diameter in px. */
  chipSize?: number;
  /** Renders a hidden input so the value submits with a form. */
  name?: string;
  /** Disables every chip. */
  disabled?: boolean;
}

type Cell = { q: number; r: number };

const AXIAL: Cell[] = [
  { q: 1, r: 0 },
  { q: 1, r: -1 },
  { q: 0, r: -1 },
  { q: -1, r: 0 },
  { q: -1, r: 1 },
  { q: 0, r: 1 },
];

/** Hex cells for `count` chips in reading order: a spiral from the centre, or a triangle for three. */
function magneticSelectCells(count: number): Cell[] {
  let cells: Cell[];
  if (count === 3) {
    cells = [
      { q: 0, r: 0 },
      { q: -1, r: 1 },
      { q: 0, r: 1 },
    ];
  } else {
    cells = [{ q: 0, r: 0 }];
    for (let ring = 1; cells.length < count; ring += 1) {
      let cell = { q: AXIAL[4]!.q * ring, r: AXIAL[4]!.r * ring };
      for (const step of AXIAL) {
        for (let i = 0; i < ring; i += 1) {
          cells.push(cell);
          cell = { q: cell.q + step.q, r: cell.r + step.r };
        }
      }
    }
    cells = cells.slice(0, count);
  }
  return cells.sort((a, b) => a.r - b.r || a.q + a.r / 2 - (b.q + b.r / 2));
}

function hexDistance(a: Cell, b: Cell): number {
  const dq = a.q - b.q;
  const dr = a.r - b.r;
  return (Math.abs(dq) + Math.abs(dr) + Math.abs(dq + dr)) / 2;
}

const unit = (value: number) => Math.min(Math.max(value, 0), 100) / 100;
const round = (value: number) => Math.round(value * 100) / 100;

type Pose = { x: number; y: number; rotate: number; scale: number };

/** Where each chip rests when `selected` is chosen, in px along the inline axis. */
function poses(cells: Cell[], selected: number, pull: number, pitch: number): Pose[] {
  const p = unit(pull);
  const push = 6.5 + 13.5 * p;
  const tilt = 5 * p;
  const row = (pitch * Math.sqrt(3)) / 2;
  const at = (cell: Cell) => ({ x: pitch * (cell.q + cell.r / 2), y: row * cell.r });
  const origin = selected >= 0 ? at(cells[selected]!) : undefined;
  return cells.map((cell, index) => {
    if (!origin) return { x: 0, y: 0, rotate: 0, scale: 1 };
    if (index === selected) return { x: 0, y: 0, rotate: 0, scale: round(1.16 + 0.22 * p) };
    const hops = hexDistance(cell, cells[selected]!);
    const here = at(cell);
    const dx = here.x - origin.x;
    const dy = here.y - origin.y;
    const length = Math.hypot(dx, dy);
    const ux = dx / length;
    const falloff = hops === 1 ? 1 : 0.9;
    return {
      x: round(ux * push * falloff),
      y: round((dy / length) * push * falloff),
      rotate: round(ux * tilt * (hops === 1 ? 1 : 0.7)),
      scale: round(1 - (hops === 1 ? 0.05 + 0.075 * p : 0.04 + 0.055 * p)),
    };
  });
}

/** Whether the element sits in right-to-left text. */
function isRtl(element: HTMLElement): boolean {
  return (
    element.closest("[dir]")?.getAttribute("dir") === "rtl" ||
    getComputedStyle(element).direction === "rtl"
  );
}

/** A honeycomb radiogroup whose chosen chip magnetically pushes the rest apart. */
export function MagneticSelect({
  ref,
  className,
  style,
  options,
  value: valueProp,
  defaultValue,
  onValueChange,
  pull = 55,
  bounce = 55,
  give = 50,
  chipSize = 44,
  stroke = false,
  name,
  disabled = false,
  onPointerMove,
  onPointerLeave,
  ...props
}: MagneticSelectProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultValue);
  const value = valueProp !== undefined ? valueProp : uncontrolled;
  const chips = useRef<(HTMLButtonElement | null)[]>([]);
  const root = useRef<HTMLDivElement | null>(null);

  const items = options.slice(0, 19);
  const cells = magneticSelectCells(items.length);
  const selected = items.findIndex((option) => option.value === value);
  const pitch = chipSize + 1;
  const layout = poses(cells, selected, pull, pitch);

  const ariaLabel = props["aria-label"];
  const ariaLabelledBy = props["aria-labelledby"];
  useEffect(() => {
    if (process.env.NODE_ENV === "production" || ariaLabel || ariaLabelledBy) return;
    console.warn(
      "[MagneticSelect] The radiogroup has no name. Pass aria-label or aria-labelledby.",
    );
  }, [ariaLabel, ariaLabelledBy]);

  // Hex centres, shifted so the top-start chip sits one margin in.
  const row = (pitch * Math.sqrt(3)) / 2;
  const centres = cells.map((cell) => ({ x: pitch * (cell.q + cell.r / 2), y: row * cell.r }));
  const margin = (chipSize * 26) / 44;
  const minX = Math.min(...centres.map((c) => c.x), 0);
  const minY = Math.min(...centres.map((c) => c.y), 0);
  const width = Math.max(...centres.map((c) => c.x), 0) - minX + chipSize + margin * 2;
  const height = Math.max(...centres.map((c) => c.y), 0) - minY + chipSize + margin * 2;

  // Give ≤ 50 settles in ~290ms; beyond that it loosens to ~800ms with a touch more wobble.
  const looseness = Math.max(give - 50, 0) / 50;
  const duration = Math.round(290 + 510 * Math.min(looseness, 1));
  const easing = springEasing(unit(bounce) + 0.15 * Math.min(looseness, 1));

  function select(index: number) {
    const option = items[index];
    if (!option || option.value === value) return;
    if (valueProp === undefined) setUncontrolled(option.value);
    onValueChange?.(option.value);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    const last = items.length - 1;
    const rtl = isRtl(event.currentTarget);
    let next: number | undefined;
    switch (event.key) {
      case "ArrowDown":
        next = index + 1;
        break;
      case "ArrowUp":
        next = index - 1;
        break;
      case "ArrowRight":
        next = rtl ? index - 1 : index + 1;
        break;
      case "ArrowLeft":
        next = rtl ? index + 1 : index - 1;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = last;
        break;
      default:
        return;
    }
    event.preventDefault();
    if (next > last) next = 0;
    if (next < 0) next = last;
    chips.current[next]?.focus();
    select(next);
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    onPointerMove?.(event);
    const element = root.current;
    if (!element || event.pointerType !== "mouse") return;
    const box = element.getBoundingClientRect();
    const reach = (value: number, size: number) =>
      `${String(round(Math.max(-1, Math.min(1, value / (size / 2 || 1))) * 2.5))}px`;
    element.style.setProperty(
      "--lx",
      reach(event.clientX - box.left - box.width / 2, box.width),
    );
    element.style.setProperty(
      "--ly",
      reach(event.clientY - box.top - box.height / 2, box.height),
    );
  }

  function handlePointerLeave(event: PointerEvent<HTMLDivElement>) {
    onPointerLeave?.(event);
    root.current?.style.removeProperty("--lx");
    root.current?.style.removeProperty("--ly");
  }

  const rootStyle: CSSProperties & Record<`--${string}`, string> = {
    width: `${String(round(width))}px`,
    height: `${String(round(height))}px`,
    "--magnetic-select-spring": easing,
    ...style,
  };

  return (
    <>
      <style href={PREFIX} precedence="dowel">
        {STYLES}
      </style>
      <div
        ref={(node) => {
          root.current = node;
          if (typeof ref === "function") ref(node);
          else if (ref) ref.current = node;
        }}
        role="radiogroup"
        aria-disabled={disabled || undefined}
        data-slot="magnetic-select"
        className={cn(
          "relative shrink-0 [--magnetic-select-dir:1] rtl:[--magnetic-select-dir:-1]",
          className,
        )}
        style={rootStyle}
        onPointerMove={handlePointerMove}
        onPointerLeave={handlePointerLeave}
        {...props}
      >
        {items.map((option, index) => {
          const pose = layout[index]!;
          const checked = index === selected;
          const centre = centres[index]!;
          const tabbable = selected >= 0 ? checked : index === 0;
          const itemStyle: CSSProperties = {
            width: chipSize,
            height: chipSize,
            insetInlineStart: round(centre.x - minX + margin),
            top: round(centre.y - minY + margin),
            translate: `calc(var(--magnetic-select-dir) * ${String(pose.x)}px) ${String(pose.y)}px`,
            rotate: `calc(var(--magnetic-select-dir) * ${String(pose.rotate)}deg)`,
            scale: `1 ${String(pose.scale)}`,
            transitionDuration: `calc(${String(duration)}ms * var(--motion-scale))`,
            zIndex: checked ? 1 : undefined,
          };
          return (
            <button
              key={option.value}
              ref={(node) => {
                chips.current[index] = node;
              }}
              type="button"
              role="radio"
              aria-checked={checked}
              aria-label={option.label}
              tabIndex={tabbable ? 0 : -1}
              disabled={disabled}
              data-slot="magnetic-select-item"
              data-state={checked ? "checked" : "unchecked"}
              className={cn(
                "group/chip absolute rounded-full",
                "transition-[translate,rotate,scale] ease-[var(--ease-overshoot)]",
                "disabled:pointer-events-none disabled:opacity-55",
                focusRing,
              )}
              style={itemStyle}
              onClick={() => {
                select(index);
              }}
              onKeyDown={(event) => {
                handleKeyDown(event, index);
              }}
            >
              <span
                data-slot="magnetic-select-body"
                aria-hidden="true"
                className="block size-full transition-[scale] ease-[var(--ease-overshoot)]"
                style={{
                  scale: `${String(pose.scale)} 1`,
                  transitionDuration: `calc(${String(Math.round(duration * 0.8))}ms * var(--motion-scale))`,
                }}
              >
                <span
                  data-slot="magnetic-select-skin"
                  className={magneticSelectVariants({ stroke, selected: checked })}
                >
                  <span
                    data-slot="magnetic-select-fill"
                    className={cn(
                      "absolute inset-0 transition-opacity duration-[calc(240ms*var(--motion-scale))]",
                      option.image
                        ? "bg-cover bg-center"
                        : "bg-[radial-gradient(circle_at_30%_25%,var(--color-info),var(--color-primary)_55%,var(--color-success))]",
                      checked ? "opacity-100" : "opacity-0",
                    )}
                    style={
                      option.image
                        ? { backgroundImage: `url(${JSON.stringify(option.image)})` }
                        : undefined
                    }
                  />
                  {option.content != null ? (
                    <span className="absolute inset-0 grid place-items-center">
                      {option.content}
                    </span>
                  ) : null}
                </span>
              </span>
            </button>
          );
        })}
      </div>
      {name !== undefined ? <input type="hidden" name={name} value={value ?? ""} /> : null}
    </>
  );
}

export { magneticSelectVariants };
ui/magnetic-select-spring.ts
/*
 * A damped spring expressed as a CSS `linear()` easing, so a state-driven
 * spring (overshoot, settle) runs as a plain CSS transition: no animation
 * library, and the global reduced-motion rule and --motion-scale still apply.
 *
 * `bounce` 0..1 (0 = critically damped, no overshoot; 1 = very lively).
 * Returns the easing string. Pair it with a duration from the caller.
 * Browsers without linear() drop the declaration, so set it through a CSS
 * variable consumed inside `@supports (transition-timing-function: linear(0, 1))`
 * in the hoisted stylesheet, with a cubic-bezier fallback in the class list.
 */
export function springEasing(bounce: number, samples = 48): string {
  const b = Math.min(Math.max(bounce, 0), 1);
  const zeta = 1 - b * 0.7; // damping ratio 1 → 0.3
  // Choose ω so the envelope has decayed to ~0.1% at t = 1.
  const omega = Math.log(1000) / zeta;
  const points: string[] = [];
  for (let i = 0; i <= samples; i += 1) {
    const t = i / samples;
    let x: number;
    if (zeta >= 0.999) {
      x = 1 - Math.exp(-omega * t) * (1 + omega * t);
    } else {
      const wd = omega * Math.sqrt(1 - zeta * zeta);
      x =
        1 -
        Math.exp(-zeta * omega * t) *
          (Math.cos(wd * t) + ((zeta * omega) / wd) * Math.sin(wd * t));
    }
    points.push(i === samples ? "1" : String(Math.round(x * 1000) / 1000));
  }
  return `linear(${points.join(", ")})`;
}