TL;DR Make the bead a button with role="switch", grab it with setPointerCapture, and keep the pull distance in a ref written to a CSS variable each frame. On release, flip only past a threshold, then snap back with a damped spring: velocity first, then position, dt capped at 1/30 s. Add touch-action: none for phones and a click and keyboard path, because WCAG 2.5.7 says dragging can't be the only way.
A pull cord toggle is a switch you drag down and let go, like the cord on an old ceiling lamp. In React and Tailwind you need three things: pointer events with setPointerCapture so the drag never loses the bead, a small damped spring that snaps the cord back when you let go, and a CSS variable that the spring writes to once per animation frame. It also needs a plain click and keyboard path, or it fails people who can't drag.
I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Pull-Cord Login is a dark room with a pendant lamp: pull the lime bead, the bulb flickers on and a glass login card fades in out of a blur. The component itself uses Framer Motion. The code below is a smaller version written for this post. It uses only React and Tailwind, and it compiles with TypeScript in strict mode against React 19.1 and Next.js 15.5.
Grab. On pointerdown the bead calls setPointerCapture. From then on every pointer event for that finger or mouse goes to the bead, even when the pointer moves outside it, until pointerup or releasePointerCapture (MDN). No window listeners to add and forget to remove.
Pull. On pointermove the cord follows the pointer down. Up to 96 px it moves freely. Past that it only moves a fifth of the distance, so it feels like the cord is stretching.
Let go. On pointerup, if the bead travelled past 56 px, the light flips. Either way a spring takes the cord from wherever it is back to rest, with a small bounce.
The threshold matters more than it looks. Without it, every accidental nudge flips the light. With it, a short tug just wobbles the cord and nothing changes, which is also what a real lamp does.
Pull-Cord Login running live. Pull the cord, the lamp turns on, the login form appears.
No. A spring is two lines of maths. The force pulls the cord back toward 0 in proportion to how far it is (stiffness), and a second force slows it down in proportion to how fast it is moving (damping). Each frame you update the velocity first, then the position:
ts
export type Spring = { y: number; v: number };// One step of a damped spring pulling y back to 0.// Velocity first, then position (semi-implicit Euler).// Returns false once the spring has settled.export function stepSpring(s: Spring, dt: number, stiffness = 380, damping = 18): boolean { const a = -stiffness * s.y - damping * s.v; s.v += a * dt; s.y += s.v * dt; return Math.abs(s.y) > 0.5 || Math.abs(s.v) > 5;}
With a stiffness of 380 and damping of 18, the spring is underdamped: released from a full 96 px pull it overshoots about 16 px above rest, swings back and settles in about half a second. I checked those numbers by running the same step at 60 frames per second. Raise the damping toward 39 and the bounce goes away. Lower it and the cord swings longer.
Two details keep it stable. The component caps each step at 1/30 of a second, so a dropped frame or a tab coming back from the background can't produce a huge step that throws the cord off screen. And updating velocity before position is what keeps this simple method from slowly gaining energy, which plain Euler (position first) tends to do.
A CSS transition can't do this part well. When you let go, the cord already has a position and a direction, and a transition always starts from zero speed along a fixed curve. The spring picks up exactly where your finger left it.
The cord moves on every pointermove and every animation frame while it springs back. Putting the position in useState would re-render the component 60 times a second for something only the cord's height cares about. Instead the position lives in a ref, and each frame writes it to one CSS variable with style.setProperty('--pull', ...). The cord's height reads it with calc(120px + var(--pull, 0px)).
React state only holds the thing the rest of the UI needs to know: whether the light is on.
The bead is a real button with role="switch" and aria-checked, so a screen reader announces it as an on or off switch with a name.
touch-none sets touch-action: none on the bead. Without it, dragging down on a phone scrolls the page instead of pulling the cord, because the browser handles the pan itself (MDN).
The hit area is 44 px even though the visible bead is 20 px.
onClick toggles too. A drag sets moved, so the click that the browser fires after a drag is ignored and the light doesn't flip twice. A tap with no movement, or Enter or Space on the focused bead, flips it and gives the cord a small kick so it still looks pulled.
While the light is off, the content is inert. It is invisible, and inert also keeps it out of the tab order and away from screen readers, so nobody tabs into a form they can't see.
The lamp is a client component, but the page around it doesn't have to be. The form is passed in as children, so it stays a Server Component and posts to a Server Action:
Only the lamp ships JavaScript for the cord. The form works the way any Server Action form does, and you can wire it to your own auth. If you want the form itself done well, Accessible Login Form in React covers labels, errors and focus.
It can be. WCAG 2.2 has a rule for exactly this: anything you can do by dragging must also be possible with a single pointer without dragging, unless dragging is essential (Success Criterion 2.5.7, Level AA). A pull cord isn't essential, it's decoration on top of an on/off switch, so the click path above is required, not optional.
For people who prefer less motion, the component skips the spring and puts the cord straight back, and the fades use motion-reduce:transition-none. prefers-reduced-motion in React goes deeper on that. One more thing to decide: hiding a login form behind a light switch is fun on a portfolio or a launch page. On a product people sign into every day, consider starting with the light on.
If you ask Cursor, v0 or Lovable for a draggable pull cord, check the result for these:
Drag is the only way to toggle. Look for a click handler and keyboard support on the bead. Without them it fails WCAG 2.5.7.
The page scrolls on phones. Check for touch-none (or touch-action: none) on the bead, and test on a real phone, not just desktop dev tools.
Listeners on window with no capture. Code that adds mousemove to the window on mousedown often forgets to remove it, or loses the drag when the pointer leaves the bead. setPointerCapture on the bead avoids both.
Position in React state. A useState that updates on every pointer move works, but re-renders the whole component each time. Check that the moving value lives in a ref and goes to the DOM directly.
The light flips twice. If both pointerup and click toggle, a drag fires both. Check that a drag and a tap are told apart.
A prompt that avoids most of these:
text
Build a pull cord toggle in React + Tailwind with no animation library.
The bead is a button with role="switch" and aria-checked. Drag uses pointer events
with setPointerCapture and touch-action: none. Keep the pull distance in a ref and
write it to a CSS variable once per requestAnimationFrame. On release, flip only if
pulled past a threshold, then return with a damped spring (velocity then position,
dt capped at 1/30 s). A click, Enter or Space also toggles, but not right after a drag.
Respect prefers-reduced-motion.
The Pull-Cord Login ($3) goes further than this post: a pendant lamp with a flickering bulb and a warm light cone, a glass login card with validation and a shake on errors, and loading and success states. It comes as plain HTML/CSS/JS and as a React + TypeScript + Tailwind version, with a PROMPT.md for AI coding agents. The pull-cord login form page shows how to drop it into Next.js. If you like this kind of login, the spotlight effect post builds a light that follows the focused field, and the hold to delete button post uses pointer capture for a press and hold.
Use a button as the bead. On pointerdown call setPointerCapture, on pointermove set the pull distance from clientY, and write it to a CSS variable that drives the cord's height. On pointerup flip the state if the pull passed a threshold, then animate back with a damped spring in requestAnimationFrame.
No. A damped spring is a few lines: acceleration is minus stiffness times position minus damping times velocity, then update velocity and position each frame. Libraries help when you have many springs or gestures to coordinate. For one cord, the version above is enough.
The browser is handling the touch as a pan. Set touch-action: none on the element you drag (touch-none in Tailwind) so the pointer events reach your code instead.
Only if dragging isn't the only way to use it. Make the bead a button with role="switch" and aria-checked, let a click, Enter or Space toggle it, and keep anything hidden while the light is off inert so keyboard users can't tab into it.