# Draggable Pull Cord Toggle in React and Tailwind

> 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.

- URL: https://qodesign.dev/blog/draggable-pull-cord-toggle-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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.

## How does a pull cord toggle work?

There are three phases.

1. 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](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture)). No window listeners to add and forget to remove.
2. 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.
3. 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.

## Do you need a spring library for this?

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.

## Why write to a CSS variable instead of React state?

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 component

```tsx
'use client';

import { useEffect, useRef, useState, type ReactNode } from 'react';
import { stepSpring } from './spring';

const MAX_PULL = 96; // px the bead travels before it gets stiff
const THRESHOLD = 56; // px past which a release flips the light

type Props = {
  label?: string;
  children: ReactNode;
};

export function PullCordLamp({ label = 'Lamp', children }: Props) {
  const [on, setOn] = useState(false);
  const cordRef = useRef<HTMLDivElement>(null);
  const s = useRef({ y: 0, v: 0, startY: 0, dragging: false, moved: false, frame: 0, last: 0 });

  useEffect(() => () => cancelAnimationFrame(s.current.frame), []);

  const write = (y: number) => cordRef.current?.style.setProperty('--pull', `${y}px`);

  const tick = (now: number) => {
    const st = s.current;
    const dt = Math.min((now - (st.last || now)) / 1000, 1 / 30);
    st.last = now;
    if (stepSpring(st, dt)) {
      write(st.y);
      st.frame = requestAnimationFrame(tick);
    } else {
      st.y = 0;
      st.v = 0;
      st.frame = 0;
      st.last = 0;
      write(0);
    }
  };

  const release = (kick = 0) => {
    const st = s.current;
    cancelAnimationFrame(st.frame);
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      st.y = 0;
      st.v = 0;
      write(0);
      return;
    }
    st.v += kick;
    st.last = 0;
    st.frame = requestAnimationFrame(tick);
  };

  return (
    <div className="relative min-h-dvh overflow-hidden bg-zinc-950 text-zinc-100">
      <div
        aria-hidden="true"
        className={`pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_85%_0%,rgba(253,230,138,0.22),transparent_60%)] transition-opacity duration-500 motion-reduce:transition-none ${on ? 'opacity-100' : 'opacity-0'}`}
      />

      <div ref={cordRef} className="absolute right-10 top-0 z-10 flex flex-col items-center">
        <div className="w-px bg-zinc-500" style={{ height: 'calc(120px + var(--pull, 0px))' }} />
        <button
          type="button"
          role="switch"
          aria-checked={on}
          aria-label={label}
          className="-mt-3 grid size-11 cursor-grab touch-none place-items-center rounded-full active:cursor-grabbing focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-300"
          onPointerDown={(e) => {
            if (e.button !== 0) return;
            e.currentTarget.setPointerCapture(e.pointerId);
            const st = s.current;
            cancelAnimationFrame(st.frame);
            st.dragging = true;
            st.moved = false;
            st.v = 0;
            st.startY = e.clientY - st.y;
          }}
          onPointerMove={(e) => {
            const st = s.current;
            if (!st.dragging) return;
            const raw = e.clientY - st.startY;
            if (Math.abs(raw) > 4) st.moved = true;
            // Free travel up to MAX_PULL, then a fifth of the movement.
            st.y = raw <= 0 ? 0 : raw <= MAX_PULL ? raw : MAX_PULL + (raw - MAX_PULL) * 0.2;
            write(st.y);
          }}
          onPointerUp={() => {
            const st = s.current;
            if (!st.dragging) return;
            st.dragging = false;
            if (st.y >= THRESHOLD) setOn((prev) => !prev);
            release();
          }}
          onPointerCancel={() => {
            s.current.dragging = false;
            release();
          }}
          onClick={() => {
            // A drag already decided. A plain click, Enter or Space toggles here.
            if (s.current.moved) {
              s.current.moved = false;
              return;
            }
            setOn((prev) => !prev);
            release(900);
          }}
        >
          <span className={`size-5 rounded-full transition-colors ${on ? 'bg-lime-300' : 'bg-zinc-400'}`} />
        </button>
      </div>

      <div
        inert={!on}
        className={`relative transition duration-500 motion-reduce:transition-none ${on ? 'opacity-100 blur-0' : 'opacity-0 blur-sm'}`}
      >
        {children}
      </div>
    </div>
  );
}
```

A few choices worth pointing out:

- 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](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)).
- 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.

## Using it on a Next.js sign-in page

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:

```tsx
import { redirect } from 'next/navigation';
import { PullCordLamp } from './PullCordLamp';

export default function SignInPage() {
  async function signIn(formData: FormData) {
    'use server';
    const email = String(formData.get('email') ?? '');
    const password = String(formData.get('password') ?? '');
    if (!email || !password) return;
    // Check the credentials with your auth provider here.
    redirect('/dashboard');
  }

  return (
    <PullCordLamp label="Lamp, turn on to sign in">
      <form
        action={signIn}
        className="mx-auto mt-40 grid w-full max-w-sm gap-4 rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur"
      >
        <h1 className="text-xl font-semibold">Sign in</h1>
        <label className="grid gap-1 text-sm">
          Email
          <input name="email" type="email" autoComplete="email" required className="rounded-lg bg-zinc-900 px-3 py-2" />
        </label>
        <label className="grid gap-1 text-sm">
          Password
          <input name="password" type="password" autoComplete="current-password" required className="rounded-lg bg-zinc-900 px-3 py-2" />
        </label>
        <button type="submit" className="rounded-lg bg-lime-300 px-4 py-2 font-medium text-zinc-950">
          Sign in
        </button>
      </form>
    </PullCordLamp>
  );
}
```

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](/blog/accessible-animated-login-form-react) covers labels, errors and focus.

## Is a pull cord accessible?

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](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html)). 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](/blog/prefers-reduced-motion-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.

## Bugs to check for in AI-generated pull cord code

If you ask Cursor, v0 or Lovable for a draggable pull cord, check the result for these:

1. 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.
2. 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.
3. 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.
4. 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.
5. 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 full version

The [Pull-Cord Login](/components/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](/react/pull-cord-login-form) shows how to drop it into Next.js. If you like this kind of login, the [spotlight effect post](/blog/spotlight-effect-react-tailwind-css-mask) builds a light that follows the focused field, and the [hold to delete button post](/blog/hold-to-delete-button-react-tailwind) uses pointer capture for a press and hold.

## FAQ

### How do I make a draggable pull cord in React?

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`.

### Do I need Framer Motion or a spring library for this?

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.

### Why does my page scroll when I drag the cord on a phone?

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.

### Is a pull cord toggle accessible?

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.
