Skip to content

Conversation

@RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Sep 4, 2025

This PR fixes an issue where you cannot use @variant inside a @custom-variant. While you can use @variant in normal CSS, you cannot inside of @custom-variant. Today this silently fails and emits invalid CSS.

@custom-variant dark {
  @variant data-dark {
    @slot;
  }
}
<div class="dark:flex"></div>

Would result in:

.dark\:flex {
  @variant data-dark {
    display: flex;
  }
}

To solve it we have 3 potential solutions:

  1. Consider it user error — but since it generates CSS and you don't really get an error you could be shipping broken CSS unknowingly.
  2. We could try and detect this and not generate CSS for this and potentially show a warning.
  3. We could make it work as expected — which is what this PR does.

Some important notes:

  1. The evaluation of the @custom-variant only happens when you actually need it. That means that @variant inside @custom-variant will always have the implementation of the last definition of that variant.

    In other words, if you use @variant hover inside a @custom-variant, and later you override the hover variant, the @custom-variant will use the new implementation.

  2. If you happen to introduce a circular dependency, then an error will be thrown during the build step.

You can consider it a bug fix or a new feature it's a bit of a gray area. But
one thing that is cool about this is that you can ship a plugin that looks like
this:

@custom-variant hocus {
  @variant hover {
    @slot;
  }

  @variant focus {
    @slot;
  }
}

And it will use the implementation of hover and focus that the user has defined. So if they have a custom hover or focus variant it will just work.

By default hocus:underline would generate:

@media (hover: hover) {
  .hocus\:underline:hover {
    text-decoration-line: underline;
  }
}

.hocus\:underline:focus {
  text-decoration-line: underline;
}

But if you have a custom hover variant like:

@custom-variant hover (&:hover);

Then hocus:underline would generate:

.hocus\:underline:hover, .hocus\:underline:focus {
  text-decoration-line: underline;
}

Test plan

  1. Existing tests pass
  2. Added tests with this new functionality handled
  3. Made sure to add a test for circular dependencies + error message
  4. Made sure that if you "fix" the circular dependency (by overriding a variant) that everything is generated as expected.

Fixes: #18524

@RobinMalfait RobinMalfait requested a review from a team as a code owner September 4, 2025 22:00
@RobinMalfait RobinMalfait force-pushed the feat/use-variant-in-custom-variant branch from 828f644 to 6636897 Compare September 4, 2025 22:01
name,
(r) => {
let body = structuredClone(ast)
if (usesAtVariant) substituteAtVariant(body, designSystem)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically evaluated lazily and every time this function is called instead of ahead of time.

But once we compute a variant we cache it anyway so should be fine.

{
compounds: compoundsForSelectors(selectors),
},
{ compounds: compoundsForSelectors(selectors) },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom variants that re-use existing @variants are not compoundable. I had an implementation where the @variant substitution was happening ahead of time to get the selectors out but we have some checks in in-* variant for example that doesn't allow nesting and @variant … is implemented using nesting...

I think this is something we can solve in a separate PR and probably after handling flattening ourselves... can of worms.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine imo. The problem here is the nested style rules are the things that disqualify it because otherwise we'll have to implement flattening ourselves.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, could be a follow-up PR, but this PR on its own is already an improvement.

for (let name of customVariants.keys()) {
// Pre-register the variant to ensure its position in the variant list is
// based on the order we see them in the CSS.
designSystem.variants.static(name, () => {})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open for suggestions here. Like maybe a designSystem.variants.reserve(name) but felt silly to introduce a new method just for this...

But my idea was to have the same behavior as-if you are overwriting internal variants that should maintain the sort order.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an argument for keeping the position of the lastly defined @custom-variant because otherwise if you were relying on a library that now introduces a @custom-variant with the same name, the sort order will be different.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is fine. Keeping the last defined one would probably be more CSS-y but it would make overriding builtin variants different from custom ones and I'd prefer that they act the same.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep agree

let seen = new Set<Key>()
let wip = new Set<Key>()

let sorted: Key[] = []
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could make topologicalSort a Generator which has a nice API, but that's way slower compared to this array-based implementation:

Image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this with a very simple graph:

let graph = new Map<string, Set<string>>([
  ['A', new Set(['B', 'C'])],
  ['B', new Set(['D'])],
  ['C', new Set(['D'])],
  ['D', new Set(['E'])],
  ['E', new Set()],
])

But I don't think the real graphs will be much more complex anyway...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'd keep the array in this case. The perf tradeoff makes sense when the memory hit is possibly unbounded but you're likely to hit other perf and memory problems with large CSS files first.

@RobinMalfait
Copy link
Member Author

Note: we can likely use the topologicalSort utility when handling @apply, but didn't want to touch that as part of this PR because it might have to change a little bit to get the graph in a shape we can work with.

Might do a follow PR for this.

}

// Update the variant at-rule node, to be the `&` rule node
replaceWith(node)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we never mutate the selector of the & node, then we could skip some layers by using:

Suggested change
replaceWith(node)
replaceWith(node.nodes)

But the final result should be the same. This is also the same behavior we had before so I think we can keep it like this.

// CSS-in-JS object
else if (typeof variant === 'object') {
designSystem.variants.fromAst(name, objectToAst(variant))
designSystem.variants.fromAst(name, objectToAst(variant), designSystem)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kinda wish we didn't have to pass the design system into something hanging off the design system :D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, one way of solving this is that we put a variantFromAst onto the DesignSystem itself 🤔

or, we can pass in a callback if we want to do something with the ast (body in this case) so the logic lives in the callsite.

I think for now, this is fine because it's all private anyway.

@RobinMalfait RobinMalfait enabled auto-merge (squash) September 5, 2025 12:19
@RobinMalfait RobinMalfait merged commit 77b3cb5 into main Sep 5, 2025
7 checks passed
@RobinMalfait RobinMalfait deleted the feat/use-variant-in-custom-variant branch September 5, 2025 12:24
@rozsazoltan
Copy link
Contributor

Cool.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested @variant inside @custom-variant

3 participants