Skip to main content

Toggle Input with TailwindCSS 2.0.

Building an accessible toggle input that can be used in a form with TailwindCSS

Introduction

I've always found form styles something of a frustration to write. Whether you agree that they should be styled or not, they can be one of the most awkward elements to get right. I've often spent hours cross-browser testing, debugging accessibility issues, or been distracted for longer than necessary with mobile inconsistencies and visual rendering issues. And if the system you are styling in has a rigid HTML structure (maybe dictated by a back-end or CMS) then it can be even more difficult.

In 2021 I use a lot of utility classes in my HTML, and I'm particularly fond of TailwindCSS at the moment. My Personal websites are using it, and I'm lucky enough to be using it at work, on live production sites.

Forms are especially difficult to get right with TailwindCSS: you either needed to use the forms plugin (with v1, which gave you a lot of control, but was just a worse and more confusing version of simply writing CSS or v2 which provides some basic resets), or you write them yourself as standard CSS. And even writing them with CSS usually requires some clever thinking.

Yes, we totally can do this by writing normal CSS but when using a utility first library it feels wrong to still have CSS that is separated from your HTML, and that can potentially cause an issue if it gets tampered with. And even then, as is the case with a lot of non-utility CSS the HTML structure often is mirrored leaving very little in terms of flexibility if a component changes or something new needs to be added. Often just to add a label, shadow, or border to a custom-styled form control we need to entirely alter the markup or write something hacky.

So I'm always looking for ways to maximise the use of Tailwind, that can scale up to be useful across an entire project, and then be re-used in the future.

Recently I was passed a design for a form control that technically is a checkbox (on or off state) but visually behaves likes a toggle.

The first rule of ARIA is if a native HTML element or attribute has the semantics and behaviour you require, use it instead of re-purposing an element and adding ARIA. Instead use the native HTML checkbox of <input type="checkbox">, which natively provides all the functionality required

I have no interest in writing accessibility controls from scratch when the spec includes but is bound to a checkbox input type. I have done this with TailwindCSS and Vue before, and I was heavily reliant on javascript to add and remove some classes but this design was for a big application, which we wanted to reduce reliance on JavaScript for things that can be done with just CSS. I initially reached for my stylesheet, but then I wondered:

Can we do this with just TailwindCSS variants or plugins?

The answer is Yes, especially now that we have Just-In-Time mode (JIT) in V2, we can do it without even worrying about file-size exploding.

Basic Markup

Let's start with the basic markup

<input type="checkbox" name="toggle-1" id="toggle-1" class="" />
<label for="toggle-1" class="">I am the walrus</label>

Now in the original version, I built for work my design does not have an actual label of text, rather the button itself will contain a graphic that is obvious what the active state refers to. In my case, it will be a cart picture to imply active shopping purchase or enable delivery, which in the content and context of the design system it's in makes sense.

However, for this to be a better component we should take into account that we may want a real-life label visible. We should also be able to click on the label for it to activate the toggle, as this is the expected behaviour of form controls in browsers.

Non-utilitarian Way

So if writing CSS and not using a utility library we might do something like wrap the items in a div and place the label next to it and style the label.

Sara Soueidan writes a lot about this and her article on Inclusively Hiding & Styling Checkboxes and Radio Buttons is the most recent and best read for HTML structure and ....

<div class="relative form-toggle">
  <input
    type="checkbox"
    name="toggle-2"
    id="toggle-2"
    class="form-input-hidden"
  />
  <label for="toggle-2" class="">
    <div class="">
      <span
        class="absolute top-0 left-0 w-10 h-10 -mt-px -ml-px transition-all bg-white border border-gray-300 rounded-md checked-sibling-child:left-2"
      ></span>
    </div>

    <span>I am the walrus</span>
  </label>
</div>

and write some css:

.form-toggle {
  + label span svg {
    transform: scale(0, 0);
  }
  &:focus + label span {
    @apply border-accent /*shadow-outline*/;
  }

  &:checked + label span {
    @apply border-accent;

    svg {
      transform: scale(1, 1);
    }
  }
}

The Tailwind Way

lets do it with tailwind:

for example:

const checkedSiblingPlugin = plugin(function ({ addVariant, e }) {
  addVariant("checked-sibling", ({ container }) => {
    container.walkRules((rule) => {
      rule.selector = `:checked + .checked-sibling\\:${rule.selector.slice(1)}`;
    });
  });
});

has the ability to let us write:

<input type="checkbox" name="toggle-1" id="toggle-1" class="" />
<label for="toggle-1" class="checked-sibling:bg-green-500"
  >I am the walrus</label
>

and with JIT we get a class definition created for us:

:checked + .checked-sibling\:bg-green-500 {
  --tw-bg-opacity: 1;
  background-color: rgba(16, 185, 129, var(--tw-bg-opacity));
}

because we made this as a plugin, and we are using JIT we can use ANY class in combination that we want.

We can even go further in our css creation to let us target children of the sibling when something is :checked:

const checkedSiblingChildPlugin = plugin(function ({ addVariant, e }) {
  addVariant("checked-sibling-child", ({ container }) => {
    container.walkRules((rule) => {
      rule.selector = `:checked + * .checked-sibling-child\\:${rule.selector.slice(
        1
      )}`;
    });
  });
});

Now in our HTML, we can

<input type="checkbox" name="toggle-1" id="toggle-1" class="" />
<label for="toggle-1">
  <div class="checked-sibling:bg-green-500"></div>
  <span>I am the walrus</span>
</label>

and in our css we get:

:checked + * .checked-sibling-child\:bg-green-500 {
  --tw-bg-opacity: 1;
  background-color: rgba(16, 185, 129, var(--tw-bg-opacity));
}

Performance-wise I have it in my head that using * may not be the best idea, but based on reading some new (and admittedly some very old) articles this doesn't seem to be the case. Of course, you can always target the label element instead of using the universal selector if this is a concern, and amend the plugin as necessary. But hopefully, you can see how this opens up a world of great possibilities with utility CSS classes.

a final tailwind.config.js maybe looks like this:

write some extras in our config file

const colors = require("tailwindcss/colors");

const plugin = require("tailwindcss/plugin");

const checkedSiblingPlugin = plugin(function ({ addVariant, e }) {
  addVariant("checked-sibling", ({ container }) => {
    container.walkRules((rule) => {
      rule.selector = `:checked + .checked-sibling\\:${rule.selector.slice(1)}`;
    });
  });
});

const checkedSiblingChildPlugin = plugin(function ({ addVariant, e }) {
  addVariant("checked-sibling-child", ({ container }) => {
    container.walkRules((rule) => {
      rule.selector = `:checked + * .checked-sibling-child\\:${rule.selector.slice(
        1
      )}`;
    });
  });
});

const focusedSiblingPlugin = plugin(function ({ addVariant, e }) {
  addVariant("focused-sibling", ({ container }) => {
    container.walkRules((rule) => {
      rule.selector = `:focus-visible + .focused-sibling\\:${rule.selector.slice(
        1
      )}`;
    });
  });
});

module.exports = {
  mode: "jit",
  theme: {
    extend: {
      colors: {
        "light-blue": colors.lightBlue,
        cyan: colors.cyan,
      },
    },
  },
  variants: {},
  plugins: [
    checkedSiblingPlugin,
    checkedSiblingChildPlugin,
    focusedSiblingPlugin,
  ],
};

while this may seem like a lot of javascript to write to generate some CSS the beauty of this is that now we can write any class we want and combine it with the :checked selector.

latest code