Animated Switch

Animated Switch

Learn how to work with Radix's unstyled primitives by building a robust switch component.

Video resources

Let's start by installing Radix's Switch library:

npm i @radix-ui/react-switch

and then rendering the anatomy:

import * as Switch from "@radix-ui/react-switch";

export default function SwitchDemo() {
  return (
    <Switch.Root>
      <Switch.Thumb />
    </Switch.Root>
  );
}

If you see this error

Error: createContext is not a function.

it's because you're rendering <Switch> inside of a Server Component. Add the use client directive to your page

"use client";

import * as Switch from "@radix-ui/react-switch";

export default function SwitchDemo() {
  return (
    <Switch.Root>
      <Switch.Thumb />
    </Switch.Root>
  );
}

and you shouldn't see any more errors.

Our page is now rendering Radix's Switch! But we don't see anything. This is because Radix is an unstyled component library, meaning it provides all the behavior, but it's up to us to give each component the look and feel we're after.

Let's start simple and get something on the screen.

Basic styling

Let's make our switch a white box:

<Switch.Root className="h-5 w-5 bg-white">
  <Switch.Thumb />
</Switch.Root>

Now we see our switch on the screen!

If we inspect the element, we'll see Radix has added several attributes related to accessibility and behavior, one of which is data-state="checked":

// The switch is on
<button role="switch" data-state="checked">
  {/* ... */}
</button>

// The switch is off
<button role="switch" data-state="unchecked">
  {/* ... */}
</button>

We can target this data state using Tailwind's data-* modifier. Let's make the background green when the switch is checked:

<Switch.Root className="data-[state=checked]:bg-green-500 h-5 w-5 bg-white">
  <Switch.Thumb />
</Switch.Root>

Now we can toggle our check between white and green – either by clicking, or by focusing the switch with our keyboard and pressing space!

Radix has exposed everything we need to style the switch ourselves, while still managing all the state and behavior internally.

Customizing the look and feel

Let's make our switch look a bit more like our final demo. We'll give the <Root> a wider treatment:

<Switch.Root className="data-[state=checked]:bg-green-500 h-5 w-11 rounded-full bg-gray-500">
  <Switch.Thumb />
</Switch.Root>

and style the <Thumb> to be a white circle:

<Switch.Root className="data-[state=checked]:bg-green-500 h-5 w-11 rounded-full bg-gray-500">
  <Switch.Thumb className="block h-5 w-5 rounded-full bg-white" />
</Switch.Root>

Looking good!

Our thumb currently renders on the left. Let's push it to the right when it's in the checked state.

Since <Thumb> also recieves the data-state attribute, we can use the same modifier along with Tailwind's translate-x utility to move it right:

<Switch.Root className="data-[state=checked]:bg-green-500 h-5 w-11 rounded-full bg-gray-500">
  <Switch.Thumb className="data-[state=checked]:translate-x-6 block h-5 w-5 rounded-full bg-white" />
</Switch.Root>

Now the thumb moves left and right when we toggle the switch.

Polishing up the details

Let's keep going with the design. We'll make it a bit taller, give the thumb a pixel of breathing room with p-px, add a subtle shadow, and add transitions to both components:

<Switch.Root className="data-[state=checked]:bg-green-500 w-11 rounded-full bg-gray-700 p-px transition">
  <Switch.Thumb className="data-[state=checked]:translate-x-[18px] block h-6 w-6 rounded-full bg-white shadow-sm transition" />
</Switch.Root>

We can also add an inset shadow to the <Root>:

<Switch.Root className="shadow-inner" />

It's a bit hard to see. The default shadow is black at 5% opacity, but we customize just the shadow's color with shadow-black/50:

<Switch.Root className="shadow-inner shadow-black/50" />

Nice little trick that lets us make shadow-inner stand out more without having to rewrite the whole rule ourselves!

Let's also adjust the thumb to be bg-gray-200 when its unchecked and bg-white when it's checked:

<Switch.Root className="data-[state=checked]:bg-green-500 w-11 rounded-full bg-gray-700 p-px shadow-inner shadow-black/50 transition active:bg-gray-700">
  <Switch.Thumb className="data-[state=checked]:translate-x-[18px] data-[state=checked]:bg-white block h-6 w-6 rounded-full bg-gray-200 shadow-sm transition" />
</Switch.Root>

Finally, let's change the overall accent color to sky, and add an active treatment for both the checked and unchecked states:

<Switch.Root className="data-[state=checked]:bg-sky-500 active:data-[state=checked]:bg-sky-400 w-11 rounded-full bg-gray-700 p-px shadow-inner shadow-black/50 transition active:bg-gray-700">
  <Switch.Thumb className="data-[state=checked]:translate-x-[18px] data-[state=checked] :bg-white block h-6 w-6 rounded-full bg-gray-200 shadow-sm transition" />
</Switch.Root>

Looking great!

Focus state

Let's style the focus state. We'll use Tailwind's outline utilites with no focus prefix to get it looking good:

<Switch.Root className="outline outline-2 outline-offset-2 outline-sky-400" />

and then add the focus-visible prefix so they only apply when we focus the switch with our keyboard:

<Switch.Root className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-400" />

If you haven't used it before, the focus-visible modifier targets the :focus-visible CSS pseudostate, which only applies the matching CSS rules if an element has been focused with a device that should result in the focus state being visible.

It's up to browsers to determine whether focusing with a pointing device, the keyboard, or programmatically with JS should apply focus-visible rules, but in this case, it works perfectly for our switch! Our outline styles are applied when we focus the switch by pressing Tab, but not when we interact with it using our mouse.

Adding a label

Our switch is looking good! Let's add a label to it.

<label className="flex space-x-4">
  <span className="font-medium">Airplane mode</span>
  <Switch.Root className="data-[state=checked]:bg-sky-500 active:data-[state=checked]:bg-sky-400 w-11 rounded-full bg-gray-700 p-px shadow-inner shadow-black/50 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-400 active:bg-gray-600">
    <Switch.Thumb className="data-[state=checked]:translate-x-[18px] data-[state=checked]:bg-white block h-6 w-6 rounded-full bg-gray-200 shadow-sm transition" />
  </Switch.Root>
</label>

Just like native HTML <input> elements, we can associate the label with our switch by nesting it inside. When we click the label text, our switch toggles on and off.

Controlled component

Our switch's UI is complete! But how do we actually use it?

Radix exposes two props – checked and onCheckedChange – that the calling component can use to update its own state.

Let's make some new state in our page:

"use client";

import * as Switch from "@radix-ui/react-switch";
import { useState } from "react";

export default function SwitchDemo() {
  let [airplaneMode, setAirplaneMode] = useState(false);

  // ...
}

...and pass it into our switch:

<label>
  <span>Airplane mode</span>
  <Switch.Root checked={airplaneMode} onCheckedChange={setAirplaneMode}>
    <Switch.Thumb />
  </Switch.Root>
</label>

Now if we render this state in a new p tag:

"use client";

import * as Switch from "@radix-ui/react-switch";
import { useState } from "react";

export default function SwitchDemo() {
  let [airplaneMode, setAirplaneMode] = useState(false);

  return (
    <div>
      <p>Airplane mode is {airplaneMode ? 'on' : 'off'}

      <label>
        <span>Airplane mode</span>
        <Switch.Root checked={airplaneMode} onCheckedChange={setAirplaneMode}>
          <Switch.Thumb />
        </Switch.Root>
      </label>
    </div>
  );
}

we can see our custom switch updates our page's state!

We've turned our switch into a controlled component, making it possible for callers to use it to display and update their own state.

Uncontrolled component

There's one more way we can use our new custom switch. You may know that native <input> elements are automatically serialized by the browser when they're submitted as part of a form:

<form
  action={(formData) => {
    let data = Object.fromEntries(formData);

    console.log(data);
  }}
>
  <label>
    Agree to terms? <input type="checkbox" name="native-checkbox" />
  </label>

  <button type="submit">Save</button>
</form>

If we submit this form and the checkbox is off, the data object logged to the console is empty. But if we check the checkbox and submit, we'll see it show up in the formData:

// When <input type='checkbox' name='native-checkbox'> is unchecked:
> {}

// When <input type='checkbox' name='native-checkbox'> is checked:
> { "native-checkbox": "on" }

Using an <input> element in this way is referred to as using it as an uncontrolled component, since it keeps track of whether its checked or not internally without interacting with any external React state.

And it turns out that Radix's Switch supports the same behavior!

If we give our switch a name prop and put it in a form:

<form
  action={(formData) => {
    let data = Object.fromEntries(formData);

    console.log(data);
  }}
>
  <Switch.Root name="airplane-mode">{/* ... */}</Switch.Root>

  <button type="submit">Save</button>
</form>

when we submit the form, we'll see the same semantics as a native checkbox:

// When <Switch name='airplane-mode'> is unchecked:
> {}

// When <Switch name='airplane-mode'> is checked:
> { "airplane-mode": "on" }

This means we can use our fancy new custom switch in a form, without needing to wire up any React state at all.

Using Radix components in this uncontrolled way is a perfect fit for modern React frameworks like Remix and Next.js, since they rely on HTML <form>s and FormData as the primary way to get data from our frontend back to our server.

But, if we ever need more control and want to use our switch to dynamically update some React state on the client, we can opt-in to the controlled version using checked and onCheckedChange.

Radix has given us the primitives to build a UI component with a totally custom design, while still ensuring it has all the semantics, accessibility, and behavior that users on any device expect from the browser's native checkbox element.

Links

Buy Advanced Radix UI

Buy the course

$199one-time payment

Get everything in Advanced Radix UI.

  • 2+ hours of video
  • 4 lessons
  • Private Discord
  • Summaries with code
  • Unlimited access to course materials

Lifetime membership

$349
access all coursesone-time payment

Get lifetime access to every Build UI course, including Advanced Radix UI, forever.

  • Access to all five Build UI courses
  • Full access to all future Build UI courses
  • New videos added regularly
  • Refactoring videos on React
  • Private Discord
  • Summaries with code

What's included

Stream or download every video

Watch every lesson directly on Build UI, or download them to watch offline at any time.

Live code demos

Access to a live demo of each lesson that runs directly in your browser.

Private Discord

Chat with Sam, Ryan and other Build UI members about the lessons – or anything else you're working on – in our private server.

Video summaries with code snippets

Quickly reference a lesson's material with text summaries and copyable code snippets.

Source code

Each lesson comes with a GitHub repo that includes a diff of the source code.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!