Originals
FreePull 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.
npx @galactrex/ui add pull-cordUsage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
onPull | () => void | — | Called once per pull past the threshold. |
label | string | — | What a pull does, for assistive tech. |
length | number | 96 | Resting cord length in px. |
threshold | number | 44 | How far past rest counts as a pull. |
handle | ReactNode | — | A custom handle instead of the bead. |
className | string | — | 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.