Account

Originals

Free

Pull Cord

Hang it from the top of a header or a panel. Drag the bead down and let go: it pulls, then springs back with a sway. Made for theme switches, 'lights out' modes and hidden easter eggs, and just as happy as a plain button from the keyboard.

Install

No key needed.

Terminal
npx @galactrex/ui add pull-cord

Usage

Example
import { PullCord } from "@/components/galactrex/pull-cord";

<PullCord label="Switch theme" onPull={() => setDark((d) => !d)} />

Source

Installs to components/galactrex/pull-cord.tsx, with the shared motion primitives beside it.

components/galactrex/pull-cord.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from "motion/react";
import { cx } from "@/components/galactrex/motion/cx";
import { useReducedMotion } from "@/components/galactrex/motion/use-reduced-motion";

export type PullCordProps = {
  /** called once per pull past the threshold */
  onPull: () => void;
  /** what a pull does, for assistive tech, e.g. "Switch to dark theme" */
  label: string;
  /** resting cord length in px */
  length?: number;
  /** how far past rest, in px, counts as a pull */
  threshold?: number;
  /** the handle; defaults to a small bead */
  handle?: ReactNode;
  className?: string;
};

/** A cord that hangs from the top of its parent. Drag the handle down and let go: past the threshold it pulls
    (a click goes through, a light switches, the theme flips), then springs back with a little sway. A click or
    Space/Enter on the handle performs the same pull with the animation, so it never needs dragging. */
export function PullCord({ onPull, label, length = 96, threshold = 44, handle, className }: PullCordProps) {
  const reduced = useReducedMotion();
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const pulled = useRef(false);
  const dragged = useRef(false);
  const tipX = useTransform(x, (v) => 24 + v);
  const tipY = useTransform(y, (v) => length + v);
  const bendX = useTransform(x, (v) => 24 + v * 0.35);
  const bendY = useTransform(y, (v) => (length + v) * 0.55);
  const path = useMotionTemplate`M 24 0 Q ${bendX} ${bendY} ${tipX} ${tipY}`;

  const release = () => {
    const spring = { type: "spring" as const, stiffness: 260, damping: reduced ? 40 : 9, mass: 0.8 };
    animate(x, 0, spring);
    animate(y, 0, spring);
  };
  const check = () => {
    if (!pulled.current && y.get() > threshold) {
      pulled.current = true;
      onPull();
    }
  };
  const tug = () => {
    pulled.current = false;
    if (reduced) {
      onPull();
      return;
    }
    animate(y, threshold + 12, { duration: 0.12, ease: "easeIn", onComplete: () => { check(); release(); } });
  };

  return (
    <div className={cx("relative inline-block w-12 select-none", className)} style={{ height: length + 64 }}>
      <svg aria-hidden className="pointer-events-none absolute left-0 top-0 overflow-visible" width={48} height={length + 64}>
        <motion.path d={path} fill="none" stroke="var(--gxui-muted)" strokeWidth={1.5} strokeLinecap="round" />
      </svg>
      <motion.button
        type="button"
        aria-label={label}
        drag
        dragMomentum={false}
        dragConstraints={{ left: -28, right: 28, top: 0, bottom: threshold + 40 }}
        dragElastic={0.12}
        onDragStart={() => { pulled.current = false; dragged.current = true; }}
        onDrag={check}
        onDragEnd={release}
        onClick={() => { if (dragged.current) { dragged.current = false; return; } tug(); }}
        style={{ x, y, top: length - 10, left: 14 }}
        className="absolute grid size-5 cursor-grab touch-none place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-gxui-ink focus-visible:ring-offset-2 focus-visible:ring-offset-gxui-bg active:cursor-grabbing"
      >
        {handle ?? <span aria-hidden className="block size-5 rounded-full border border-gxui-ink bg-gxui-surface" />}
      </motion.button>
    </div>
  );
}

Props

PropTypeDefaultDescription
onPull() => void—Called once per pull past the threshold.
labelstring—What a pull does, for assistive tech.
lengthnumber96Resting cord length in px.
thresholdnumber44How far past rest counts as a pull.
handleReactNode—A custom handle instead of the bead.
classNamestring—Extra classes for the outer element.

Variants

  • Theme switch. Toggle dark and light on pull.
  • Custom handle. Pass a tiny icon or a tag as `handle`.

Accessibility

  • The handle is a real button named by `label`; Space, Enter or a click pulls it.
  • Dragging is an extra, never the only way.

Motion and reduced motion

  • Spring with low damping for the sway; the cord is a single SVG curve following motion values.
  • Reduced motion: no sway, the pull still works.