Originally published on Crafttor.

TL;DR: A good toggle switch animation in CSS starts with a real checkbox, so it works with keyboards and screen readers for free. Slide the knob with transform, fade the track colour, and add a short stretch to the knob while it moves. Finish with role="switch", a visible focus ring and a reduced-motion fallback. Or start from the Stretch Toggle in the Capture Motion gallery.

Toggles look simple, but they are one of the most touched controls in any settings screen. A flat jump from left to right works. A knob that leans into the move and settles feels like a physical switch. The extra polish costs about ten lines of CSS.

The markup: a checkbox and a label

Start with a native input. Do not build a toggle from a div and JavaScript. You would have to rebuild focus, keyboard support and form submission by hand.

<label class="switch"> <input type="checkbox" role="switch" class="switch__input"> <span class="switch__track" aria-hidden="true"> <span class="switch__knob"></span> </span> <span class="switch__label">Email notifications</span> </label> 

The role="switch" tells assistive tech this is an on and off switch, not a checkbox in a list. Screen readers then announce "on" or "off" instead of "checked". The visible track and knob are decoration, so they get aria-hidden="true".

Hide the input visually, but keep it in the page so it can take focus:

.switch__input { position: absolute; opacity: 0; width: 1px; height: 1px; } 

Avoid display: none here. It removes the input from the tab order and from the accessibility tree.

Toggle switch animation CSS: track and knob

Now style the track and the knob. Use custom properties so sizes stay in sync.

.switch { --w: 52px; --h: 30px; --pad: 3px; --knob: calc(var(--h) - var(--pad) * 2); display: inline-flex; align-items: center; gap: 12px; cursor: pointer; } .switch__track { position: relative; width: var(--w); height: var(--h); border-radius: 999px; background: #c9c9d1; transition: background-color 220ms ease-out; } .switch__knob { position: absolute; top: var(--pad); left: var(--pad); width: var(--knob); height: var(--knob); border-radius: 999px; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / .25); transition: transform 260ms cubic-bezier(.22, 1, .36, 1); } 

Slide the knob

When the input is checked, move the knob across the track. The distance is the track width minus the knob and both paddings.

.switch__input:checked + .switch__track .switch__knob { transform: translateX(calc(var(--w) - var(--knob) - var(--pad) * 2)); } 

Use transform, not left. It stays smooth because it avoids layout on every frame.

Change the track colour

The track colour is the main on and off signal. Make it clear.

.switch__input:checked + .switch__track { background: #17171c; } 

Check contrast between the knob and the track in both states. A pale grey off track on a white page can fail. A darker border on the track is a simple fix.

Add the stretch: the detail that makes it feel alive

A real knob does not teleport. A stretchy version widens while it travels, then snaps back to a circle when it lands. You can fake this with a short keyframe animation on the knob's width.

The trick is that the knob must stretch toward where it is going. Moving right, it grows on its right edge. Moving left, it grows on its left edge. Two keyframe sets handle that, one per direction.

@keyframes stretch-on { 45% { width: calc(var(--knob) * 1.35); } } @keyframes stretch-off { 45% { width: calc(var(--knob) * 1.35); margin-left: calc(var(--knob) * -0.35); } } .switch__knob { animation: stretch-off 260ms cubic-bezier(.22, 1, .36, 1); } .switch__input:checked + .switch__track .switch__knob { animation: stretch-on 260ms cubic-bezier(.22, 1, .36, 1); } 

Keyframes with only a 45% step animate from and back to the knob's normal size. The knob is pinned by left, so a wider knob grows rightward on the way on. On the way off, the negative margin-left shifts the growth to the left edge. Because the animation name changes on each flip, the browser restarts it every time.

Two trade-offs to know. Animating width and margin triggers layout, but on one small knob the cost is tiny. The off animation also plays once on page load, which most people never notice. If it bothers you, add a class to the switch after the first change and scope the animations to it.

Keep the peak stretch between 1.2 and 1.4 times the knob size. Beyond that, it reads as a bug, not a feature.

Timing guidance:

Part Duration Easing
Knob travel 220 to 300 ms Decelerating curve
Stretch peak About 40 to 50% of the travel Same curve
Track colour 180 to 240 ms ease-out

If you want a springy settle instead, CSS linear() can approximate a spring in pure CSS. Our guide on CSS linear() easing shows how to build one.

Accessibility: role, focus and reduced motion

A toggle that looks great but fails a keyboard user is a broken toggle. Cover these three points.

Role and label

You already have role="switch" and a visible text label inside the label. Clicking the text toggles the switch, and screen readers read the label. If the visible text is not enough context, add aria-describedby pointing to a helper sentence.

Focus ring

Because the input is hidden, show focus on the track instead:

.switch__input:focus-visible + .switch__track { outline: 2px solid #fb9f3d; outline-offset: 3px; } 

:focus-visible shows the ring for keyboard users without flashing it on every mouse click. Space toggles a native checkbox, so keyboard support comes for free.

Reduced motion

Respect users who ask for less motion. Drop the stretch and shorten the slide, but keep the colour change.

@media (prefers-reduced-motion: reduce) { .switch__knob { animation: none; transition-duration: 1ms; } } 

The state is still clear from the knob position and the track colour. Motion is decoration on top of that, never the only signal.

Quick accessibility checklist

  • [ ] Native input type="checkbox" with role="switch"
  • [ ] Visible text label inside the label
  • [ ] Focus ring on :focus-visible
  • [ ] On and off states differ by more than colour alone (knob position)
  • [ ] Contrast checked in both states
  • [ ] Reduced-motion fallback in place

A ready version: the Stretch Toggle in the Capture Motion gallery

Building it yourself is a good way to learn the parts. If you would rather start from a finished piece, the Capture Motion gallery has a Stretch Toggle. The knob leans toward where it is going, stretches on the way, and inverts the track as it lands.

From the gallery, you can customize it and then:

  • Send it to Figma as a Figma Motion timeline, with the keyframes and easing on tracks you can edit.
  • Copy it as code in CSS, Framer Motion or GSAP, or download it as HTML and React.

It sits next to other interactive components in the gallery. If you like the stretch idea, the liquid tabs animation uses the same leading-edge principle on a tab indicator. For a bigger gesture on a confirm action, see the slide to confirm button animation.

Seen a toggle on another site that you want to study? The Capture Motion extension can record its real keyframes, durations and easing, then export them to code or Figma. Capture or rebuild only designs you own or have permission to use.

FAQ

Should a toggle switch use a checkbox or a button?

A checkbox with role="switch" is the simplest robust choice. It works in forms, supports the Space key and reports its state. A button with aria-pressed also works, but you have to handle the state in JavaScript.

How long should a toggle switch animation be?

Around 220 to 300 ms for the knob travel. Shorter feels abrupt, longer feels sluggish on a control people flip often.

How do I make the knob stretch while it moves?

Animate scaleX or the knob width with a short keyframe that peaks near the middle of the move. Set transform-origin so the knob grows toward its direction of travel.

How do I keep the animation accessible?

Use a native checkbox with role="switch", a visible label, a :focus-visible ring and a prefers-reduced-motion fallback. Make sure the on state is clear from the knob position, not just colour.

Is there a ready-made animated toggle I can use?

Yes. The Stretch Toggle in the Capture Motion gallery can be sent to Figma as a Figma Motion timeline or copied as code.