Week of July 22 — July 26

Adam Wathan
Adam Wathan
Earlier in the week
Jordan Pittman Jordan
and I finished up support for adding basic utilities with `@utility` and merged it in to v4:

https://github.com/tailwindlabs/tailwindcss/pull/14044

The idea here was just to reach parity with what you could do in CSS in v3, so no support for custom functionality utilities like `px-*` or mapping things to the theme or anything, but you can add simple utilities with fixed properties like this one:

@utility text-trim {
  text-box-trim: both;
  text-box-edge: cap alphabetic;
}

These utilities are generated on-demand like Tailwind's built-in utilities, and are output alongside built-in utilities wherever you put `@tailwind utilities` in your CSS (usually hidden away behind your `@import "tailwindcss"` statement).

Like built-in utilities, these support variants so you can do stuff like `lg:text-trim`, which you can't do with custom CSS classes that aren't registered using `@utility`.



We spent a bunch more time in the week working on a new JS API for adding custom utilities in v4 that was designed around the new v4 engine instead of trying to map old ideas from v3 to the new engine like we'll need to do with the old `matchUtilities` API.

We like the idea of just a `utility` function that looks something like this:

export default function({ utility, css }) {
  utility('text-trim', () => {
    return css`
      text-box-trim: both;
      text-box-edge: cap alphabetic;
    `
  })
}

Where we eventually ran into issues was in supporting functional utilities. We want to expose our `candidate` AST object to the plugin author so they can inspect it and make all of the same sort of decisions we make internally for built-in utilities, but that means in reality you need a lot of boilerplate in every one of these plugins to make sure you don't accidentally support values, the negative prefix, or modifiers if your utility doesn't support those:

export default function({ utility, css }) {
  utility('text-trim', (candidate) => {
    if (candidate.value) return
    if (candidate.negative) return
    if (candidate.modifier) return
    
    return css`
      text-box-trim: both;
      text-box-edge: cap alphabetic;
    `
  })
}

We tried to think of different ways to try and make these features opt-in instead of opt-out to avoid this boilerplate, but couldn't really land on anything that felt right.

Options we considered included:
  • Some configuration object you pass in when defining a utility that includes options like `supportsModifiers`, `supportsNegatives`, and `supportsValues`. This just didn't feel elegant enough to me.
  • Some sort of builder pattern or something, where you can do `utility.withModifier('text-trim', () => { ... })`. This just felt like an unnecessarily cute version of the configuration object though.
A third option we actually kind of liked was to detect features based on a sort of format string, so you could write something like this to support a value:

export default function({ utility, css }) {
  utility('tab-*', (candidate) => {
    return css`
      tab-size: ${candidate.value.value};
    `
  })
}

Or this to support a value + modifier:

export default function({ utility, css }) {
  utility('text-*/*', (candidate) => {
    return css`
      font-size: ${candidate.value.value};
      line-height: ${candidate.modifier.value};
    `
  })
}

The hard part though is figuring out how to support the `-` prefix using a format string like this. A format string like `(-)mt-*/*` just really sucks.

Ultimately it just felt like we were trying to solve all of the same problems we were trying to solve when figuring out how to express all of this stuff in CSS.

I think we'll revisit this eventually but it was becoming such a time-sink that we decided to abandon it for now and focus on implementing backwards compatibility for `matchUtilities` instead, because even if we don't love it as an API for v4, we need to do it regardless and it'll still solve people's problems until we can figure out a v4-native API.



For the rest of the week we focused on implementing `matchUtilities` for backwards compatibility.

This honestly went pretty smoothly, maybe surprisingly so with only a handful of tricky things we had to figure out.

The `matchUtilities` API already has all sorts of options for things like `supportsNegative`, `modifiers`, and even `type` for disambiguating utilities when using arbitrary values, so most of that stuff was straightforward enough to implement as just some conditionals within the function we build up and register with the `Utilities` map in the engine.

The trickiest thing was probably thinking through migrating `theme(...)` calls. In v3, theme values used camelCase and dot notation, so you'd have calls like `theme('fontSize.sm')` which we needed to translate to `--font-size-sm` in v4.

We handle returning entire namespaces as well, so `theme('colors')` for example ultimately translates to fetching `--colors-*` from the theme. What makes this a bit tricky though is that in v3 you could have a big nested object for a theme value, like `colors` looks sort of like this:

{
  colors: {
    red: {
      100: ...,
      200: ...,
      300: ...,
    }
  }
}

In v4 though there's no concept of nested data structures in your theme, everything is flat. So fetching `theme('colors')` returns this:

{
  colors: {
      'red-100': ...,
      'red-200': ...,
      'red-300': ...,
  }
}

In practice this ends up not really mattering because the correct utilities are still generated, but I'm sure there will be some very 1% edge cases someone runs into when upgrading to v4 where something has broken for them because they are trying to directly access subproperties of the nested object that no longer exist.

Another realization we had that was pretty unfortunate is that someone might be doing something like `theme('backgroundColor')` in a v3 plugin expecting to get all of the configured background colors back, which doesn't work in v4 because the `--background-color-*` namespace is empty by default.

In v3, if you needed a plugin to look for values in two different theme keys, you'd handle that by copying the values from one theme key to another at the config level:

module.exports = {
  theme: {
    colors: ...,
    backgroundColor: ({ theme }) => ({
      ...theme('colors')
    })
  }
}

But in v4, checking two different theme keys is handled by the utility itself, for example our `bg-*` handler will first look at the `--background-color-*` namespace for a matching color, then fallback to `--colors-*` if it doesn't find a match.

So this means that for us to ensure that v3 plugins work in v4, we need to handle this fallback logic within the `theme` function, and make sure that when someone requests `theme('backgroundColor')` in their plugin that we actually return an object that merges `--colors-*` and `--background-color-*`. Similarly if someone tries to look up `theme('accentColor.red.500')` we need to look for `--accent-color-red-500` and fallback to `--colors-red-500` if it doesn't exist.

We've also changed the name of some namespaces in v4 😫 Like `colors.red.500` is now `--color-red-500` (notice the singular "color"). So that's another naming related thing we need to "upgrade" within the theme function.

Still some work to do with this crap but getting close. Hoping that this work makes `addUtilities`, `addComponents`, and `matchComponents` really easy to get in there when we're done.