Week of July 15 — July 19

Adam Wathan
Adam Wathan
Made quite a bit of progress on new CSS APIs for v4 with
Jordan Pittman Jordan
this week!

New @variant API

This was started the previous week but we started the week by merging in support for registering custom variants in CSS using two syntaxes, depending on the complexity of the variant:

/* Simple custom variant */
@variant hocus (&:focus, &:hover);

/* More complex custom variant */
@variant strict-hover {
  @media (hover: hover) {
    &:hover {
      @slot;
    }
  }
}

I'm still not entirely sold on the word `@slot` but haven't come up with something better yet. The idea is that's the marker where Tailwind will insert the rules from the utility you're modifying, and having an explicit marker makes it possible to do things like insert those rules more than once, or add additional properties to other parts of the rule.

Later in the week Jordan and I worked on making this stuff a bit more strict, because there are some complex scenarios that require a lot more work to support and our current output for those situations was nonsense.

One situation was making sure that variants that only wrap the rule in another at-rule do not compound with compound variants like `group-*` or `has-*`:

@variant any-hover (@media (any-hover: hover));

Trying to write a class like `group-any-hover:underline` just doesn't make any sense because media queries are conditions that apply to the entire current environment, not just a specific selector. There's no way a parent `group` can meet the `any-hover` condition without the entire page meeting it — it's the same reason we don't support `group-lg`.

Another thing we needed to explicitly prevent (at least for now) is using compound variants with custom variants that include nested selectors. For example, entertain this custom variant for a second even though you could write it differently:

@variant super-focus {
  &:focus {
    &:hover {
      @slot;
    }
  }
}

This is written with nesting, but the behavior is the same as `&:focus:hover`. Now think about applying the `not-*` compound variant to this custom variant — what should it do?

Intuitively you might expect that this is right:

&:not(:focus) {
  &:not(:hover) {
    ...
  }
}

...but it's not! That's the same as writing `:not(:hover):not(:focus)`, which means "the element is not hovered and the element is also not focused".

What we really want is `:not(:hover:focus)`, which is "the element is not both hovered and focused". The key difference here is that this selector does match if the element is hovered but not focused, or focused but not hovered.

Making `not-*` work with this sort of thing is easy if we can flatten the nesting ourselves but we don't do that, we let Lightning CSS do that as basically a minification step at the very end, and we operate directly on the tree while it contains nested rules.

Jordan and I eventually figured out that the right output here without worrying about flattening nesting is this:

&:not(:focus) {
  ...
}

&:not(:hover) {
  ...
}

We started working on producing that output and have it mostly figured out, but it's not 100% ready yet so in the mean time we explicitly throw out utilities that try to use things like `not-*` with custom variants that include nesting. Hopefully we can get it working and make the behavior intuitive before a proper release though.

Support overriding variants

We also added support for overriding built-in variants, by just redefining the variant using the same name.

For example, this overrides the built-in `hover` variant with one that only applies when the device actually supports hover to avoid "sticky hover" states:

@variant hover {
  @media (hover: hover) and (pointer: fine) {
    &:hover {
      @slot;
    }
  }
}

This one overrides `dark` to use a custom selector instead of relying on the `prefers-color-scheme` media query:

@variant dark (&:is(.dark, .dark *));

It was possible to override some variants this way in v3 but not all, and when you did, the new variant you registered wouldn't be in the same spot in the variant sort order as the original, which could lead to unexpected results.

Now any variant can be replaced, and when you replace a variant it preserves its spot in the sort order, so if you override `focus` it still comes before `hover`.

The other nice thing about all of this is it totally removes the need for some configuration options we had in v3. For example we don't really need the `darkMode` configuration option anymore, people can just register a different dark mode variant. We also had a `hoverOnlyWhenSupported` experimental option in v3 that we can remove now, again because someone can replace the `hover` variant with their own.

And if we don't want people to have to do the work of registering the variant by hand, we can even include these alternative variant implementations as just CSS files that you import, where we register it for you but tucked away in a file you don't have to see or touch:

@import "tailwindcss";
@import "tailwindcss/dark-mode-selector";

Really excited about how this reduces the surface area of our APIs!

Custom utilities in CSS with @utility

The last thing we worked on in the week was preliminary support for explicitly registering custom utilities with Tailwind in CSS.

It was a lot of trial and error, brainstorming, and experimenting to discover how this API needed to work, and I think to understand where we're at so far it's important to define "utility".

In a Tailwind project, a "utility" is a class that can be modified with variants (`dark:lg:my-custom-utility`), and inserted into another custom CSS rule using `@apply`.

Here's a bunch of ideas we had, challenges we ran into, and where we ended up.

New general purpose @match at-rule for registering both utilities and components

For the longest time I thought what I wanted was an API like this:
@import "tailwindcss";

@layer utilities {
  @match all-unset {
    all: unset;
  }
}
Things I liked about this:
  • It's using the native `@layer` stuff for telling the browser where the style should go, no magic
  • Being a custom at-rule we get to say "a utility must just be a single class name, it can't be a full on complex selector". People can use nesting for that stuff, but it's important that the framework knows "this is the string to look for in your HTML that should generate this CSS if found".
But it has some issues:
  • `@match all-unset` just doesn't at all look like it's creating a new class. Classes start with `.` in CSS, and with no other signal that it's a utility, it's just really not obvious what this is.
  • Where do the variants like `lg:all-unset` get inserted into the CSS? Alongside the other variants in Tailwind? If so, they will be earlier in the CSS than `all-unset` with no variants, unless we hoist that as well. And if we hoist that, the `@layer utilities` thing is pointless.
Next idea...

Make all selectors that are just single classes work as utilities

Let people write utilities as just regular CSS:

@import "tailwindcss";

@layer utilities {
  .all-unset {
    all: unset;
  }
}

Here we'd see `all-unset` and register it internally as a utility, so that things like `lg:all-unset` would work, even though you never really explicitly told Tailwind you want that to work.

This has some issues too though:
  • Do we remove `.all-unset` from the CSS if it's not used? That's not really safe because some of these classes might just be from vendor stylesheets you're importing that you know you need, so we have to keep all this CSS all the time. Seems like a bad disadvantage for custom utilities to be "unpurgeable".
  • The convention around "must be a single class to work as a utility" is not obvious — someone is going to try and write this and be surprised it doesn't work:
@import "tailwindcss";

@layer utilities {
  .mistake-red::spelling-error {
    color: red;
  }
}
  • We still don't know where to put the variants in the final CSS sort order, just like with `@match`

New @utility at-rule for explicitly registering utilities

The more me and Jordan talked about our options, the more this started to feel right:
@import "tailwindcss";

@utility all-unset {
  all: unset;
}
Here are the reasons we think this works
  • Jordan had a key insight that if the goal of this is to "register a utility" with the framework, then we really don't need the `@layer utilities`, because the whole idea is we don't actually output your class where you author it anyways. It's more like `@variant` — it just registers something with the engine, it doesn't actually produce a new CSS rule in-place. The utilities that Tailwind spits out show up wherever you write `@tailwind utilities`, and in our main `index.css` export we already wrap that in `@layer utilities` for you.
  • The name `@utility` is going to be obvious to people and it will make sense to them at `all-unset` is a class name, whereas with `@match` it's not clear what it is at all. I don't love the name because it feels like it adds this hyper-specific API to the framework and doesn't feel very CSS-y, but I'm willing to live with it because it's the least surprising API we could possibly add. Surely at this point if there's one word that's never leaving the Tailwind lexicon it's "utility".
  • At first we thought we'd need `@component` as well, but we're realizing that maybe components don't need to be a concept at all in v4. They only existed in the past for sorting reasons, but now we sort things based on their properties and the number of properties, and number of properties is kinda the defining characteristic of a "component class" anyways. They are always going to get sorted before more specific utilities no matter what. I kinda like that `prose` can just be thought of as a bigger utility instead of a component.
This is all fine and dandy for simple static utilities, but of course functional utilities (like `tab-*`) are the hard part.

We made some progress here but a lot of it is still very up in the air. Here's a simple example of the sort of syntax we've been playing with most recently:

@utility tab-* {
  tab-size: value();
}

The hard parts are when you need to support mapping to a theme namespace, supporting bare values, or specifying the type for arbitrary values. Another challenge of course is modifiers. Yet another is how to handle the fact that sometimes you want to operate on the value differently depending on whether it came from the theme, is a bare value, or is an arbitrary value.

For example, say Tailwind didn't support `opacity-*` out of the box and we wanted to write it as a custom utility. It would need to look something like this:

@utility opacity-* {
  // If arbitrary
  opacity: value();
  
  // If bare value
  opacity: calc(value() * 100%);
  
  // If theme value, need to specify the namespace
  opacity: value(--opacity-*);
}

We really don't want to have separate functions for rendering the arbitrary value, bare value, or theme value. But we do need to be able to differentiate between bare values and arbitrary values because you may need to operate on the bare value, while leaving the arbitrary value untouched.

Another issue is that Prettier hates `value(--opacity-*)` — it always inserts a space like `value(--opacity- *)`. So we either need to come up with a different syntax for resolving something from a theme namespace, or lobby for a tweak in how Prettier handles this particular thing so we can avoid that whitespace being inserted.

Overall I think we are headed in the right direction, and I like the idea of declaring your properties multiple times but just using different Tailwind features in each definition, so Tailwind can throw out the ones that resolve to null and just keep the one that works. That feels familiar to me because of how fallback properties work in CSS:

.my-rule {
  --webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

But yeah still a lot of details to get right here and a lot of API decisions to make. Learning a lot as we actually try to make something work though, even if we're mostly just learning which APIs are definitely not viable!