-
Notifications
You must be signed in to change notification settings - Fork 152
Description
Currently the styled kit leverages cva
(class-variance-authority) functions to define variants and applies them inside a cn()
utility made of clsx
+ tailwind-merge
to allow overriding the applied tailwind utility classes.
Instead of this approach, I am more and more convinced that using native css @layer is a better solution, both in itself performance and DX wise. On top of that it will allow us to more easily improve the Make it yours
capabilities for extremely different styles at the click of a button, which is a non negligible perk of Qwik UI.
Syntax
The ultimate syntax won't be that much different, except that we’ll define the utility classes in a separate css file for each component.
Note: we are working on
asChild
support, which will allow us to also not usecva
to apply variants to other html elements (e.g. apply<Button>
styles to an anchor<a>
tag).
So instead of
import { component$, type PropsOf, Slot } from '@builder.io/qwik';
import { cn } from '@qwik-ui/utils';
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
'group inline-flex items-center justify-center rounded text-sm font-medium transition-all duration-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
look: {
primary:
'border-base bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:shadow-base active:press',
secondary:
'border-base bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/90 active:shadow-base active:press',
alert:
'border-base bg-alert text-alert-foreground shadow-sm hover:bg-alert/90 active:shadow-base active:press',
outline:
'border bg-background text-foreground shadow-sm hover:bg-accent active:shadow-base active:press',
ghost: 'text-accent-foreground hover:bg-accent',
link: 'text-foreground hover:bg-transparent hover:text-foreground/80 hover:underline hover:underline-offset-2',
},
size: {
sm: 'h-8 px-2 py-1.5 text-sm',
md: 'h-12 px-4 py-3 text-base',
lg: ' h-16 px-8 py-4 text-lg',
icon: 'h-10 w-10',
},
},
defaultVariants: {
look: 'primary',
size: 'md',
},
},
);
type ButtonProps = PropsOf<'button'> & VariantProps<typeof buttonVariants>;
export const Button = component$<ButtonProps>(({ size, look, ...props }) => {
return (
<button {...props} class={cn(buttonVariants({ size, look }), props.class)}>
<Slot />
</button>
);
});
The button.tsx
will be rather small and point to a button.css
.
The css:
@import 'tailwindcss';
@reference '../path-to/global.css';
@layer components {
.btn {
@apply inline-flex cursor-pointer items-center justify-center rounded-sm text-sm font-medium transition-all duration-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50;
}
.btn-primary {
@apply bg-primary text-primary-foreground hover:bg-primary/90;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground hover:bg-secondary/90;
}
.btn-alert {
@apply bg-alert text-alert-foreground hover:bg-alert/90;
}
.btn-outline {
@apply border bg-background text-foreground hover:bg-accent;
}
.btn-ghost {
@apply text-accent-foreground hover:bg-accent;
}
.btn-link {
@apply text-foreground hover:bg-transparent hover:text-foreground/80 hover:underline hover:underline-offset-2;
}
.btn-sm {
@apply h-8 px-2 py-1.5 text-sm;
}
.btn-md {
@apply h-12 px-4 py-3 text-base;
}
.btn-lg {
@apply h-16 px-8 py-4 text-lg;
}
.btn-icon {
@apply h-10 w-10;
}
}
The tsx:
import { component$, type PropsOf, Slot, useStyles$ } from '@builder.io/qwik';
import { cn } from '@qwik-ui/utils';
import styles from './button.css?inline';
type ButtonLook = 'primary' | 'secondary' | 'alert' | 'outline' | 'ghost' | 'link';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
type ButtonProps = PropsOf<'button'> & {
look?: ButtonLook;
size?: ButtonSize;
};
export const Button = component$<ButtonProps>(({ look, size, ...props }) => {
useStyles$(styles);
return (
<button {...props} class={cn(`btn btn-${look} btn-${size}`, props.class)}>
<Slot />
</button>
);
});
Notice the use of @layer components
, which allows us to override any of the applied utilities (with @apply) for all the one-offs where it’s overkill to create/update a variant. See https://play.tailwindcss.com/sORZZZiz1I to see how @layer + @apply can work for this use-case. The utilities
layer will always overrides the utilities that are applied in the components
layers, so there are no surprises. For reusable components that use a reusable component (e.g. <ToggleGroup><Toggle /></ToggleGroup>
), we can define a new components-2
layer. If the reuse gets to 3 levels, we can create a components-3
layer, etc. It is as simple as adding @layer base, theme, components, components-2, components-3, utilities;
on top of the @import 'tailwindcss’;
in the global.css
.
This might seem unconventional at first and the syntax might look a little more verbose, but it comes with significant DX and performance benefits. Besides the verbosity, the only drawback I can think of is the refactoring cost for cases where someone wants to transform a piece of jsx into a reusable+overridable component. In that case the utilities we want to be overridable on the jsx will have to be moved into a css file, but this is usually not a frequent task.
Note: this is a pattern that will/should only be used for reusable and overridable components. Which components should be overridable is a choice that should be made by engineering and design teams as a trade-off between design system consistency and easier DX. Qwik UI components are overridable by default. For the rest of the application (layouts, pages, etc.), tailwind can (and should!) still be used for simplicity.
DX benefits
1) No overrides edge cases
css cascades with @layer will automatically and logically be applied. In contrast tailwind-merge doesn’t work in 100% of the cases and complex/large projects will inevitably need to resort to modifying the config, which is non-trivial to do and maintain.
2) More customizability
This is very specific to Qwik UI. Currently, to provide the Make it yours’ brutalist
style, we rely on a set of -base
(e.g. border-base
, shadow-base
, etc.) custom utilities added to the tailwind.config file. For .brutalist
, the -base
is often 2px bigger, and so shadow-base
will be 2px
, shadow-sm will be 4px
, etc.
There are a few problems with this approach:
- There are now
-base
utilities all over the place. The brutalist’sshadow-base
could simply beshadow-sm
andshadow-sm
beshadow
. - Sometimes the
-base
doesn’t make much sense. For exampleh-base
would be weird, but that would be the only way to achieve a brutalist Separator with the “Make it Yours”. - Sometimes we want to apply different classes to different styles: for example
hover:shadow-sm
for the basic style, andhover:inset-shadow-2xs
for the brutalist.
The new approach allows us to overcome these limitations and remove the -base
utilities all together for cleaner copy/paste components that rely purely on the base tailwind APIs.
3) less dependencies to install, less APIs to learn
Learning css @layer also requires a bit of learning, but that is a tool that can be useful in a lot of other use-cases as well. In contrast, tailwind-merge has many more APIs ([twJoin](https://github.com/dcastil/tailwind-merge/blob/v3.3.1/docs/when-and-how-to-use-it.md#how-to-use-it)
, twMerge
, and a non-trivial config.
Performance benefits
1) No 8.5kb of javascript to preload and eagerly execute

2) No more bloated html
On pages with a lot of the same reusable component(s) the html become big enough to negatively and significantly impact FCP (First Contentful Paint) and LCP (Largest Contentful Paint). Take the Qwik UI Avatar
component for example. It holds a dozen of classes, so if you have a 100 of them on a page, you multiply this dozen of utilities by a 100, which significantly increases the size of the html. This is a bit of a contrived example, but it is very common for a page to hold ~10 Avatar, ~5 Tabs, ~10 Cards, etc.
In contrast, using @layer, we create reusable class names for those components. This increases the size of the css a little bit, but for most components, the gains on the html outweighs the losses on the css as soon as the components are being used twice in the html. If a component is being used only once, then the performance impact of one more css class is negligible.
For example on Qwik UI /docs/styled/button/
the html output is 30% smaller with semantic btn btn-primary
class names applied with @layer and @apply.
3) MPA and SPA can now leverage css modules
Instead of having one giant css file for the entire app, that needs to be downloaded for the first page load on SPA and on every route navigation for MPA, we can now split the css in smaller files and request only those needed for each page. If your app is big and non trivial, this can result in a non negligible performance boost in terms of FCP/LCP.
4) No runtime execution of the cn()
function on first render and signal updates wherever it is being used
Even though the results of tailwind-merge are cached in memory, there are cases where the computation must be done on signal updates (e.g. conditional rendering). This is runtime computational cost that can be avoided.
Alternatives to css @layer and tailwind-merge for one-off overrides
None of the alternatives are satisfying enough imo, but they are still worth mentioning.
Adding props that toggle internal styles
This is the good-old way of styling components and is also probably your default. E.g. think of a variant prop that toggles between primary and secondary styles of a button. The variant
prop is already toggling between internal styles of the component and you can use the same pattern to define any number of styling use cases to a component. If you have a one-off use case to give the button a full width, you can add a isFullWidth
prop to the button component which toggles the w-full
class internally.
// React components with JSX syntax used in this example
function Button({ variant = 'primary', isFullWidth, ...props }) {
return <button {...props} className={join(BUTTON_VARIANTS[variant], isFullWidth && 'w-full')} />
}
const BUTTON_VARIANTS = {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-200 text-black',
}
function join(...args) {
return args.filter(Boolean).join(' ')
}
The problem with this approach is that the more one-offs there are, the harder the component code becomes to comprehend. Also it is not as convenient as a simple class=
override.
Using Tailwind's important modifier
If you have too many different one-off use cases to add a prop for each of them to a component, you can use Tailwind's important modifier to override internal styles.
// React components with JSX syntax used in this example
function MyComponent() {
return (
<>
<Button className="w-full">No danger</Button>
<Button className="w-full bg-red-500!" >Danger!</Button>
</>
)
}
function Button({ className ...props }) {
return <button {...props} className={join('bg-blue-500 text-white', className)} />
}
function join(...args) {
return args.filter(Boolean).join(' ')
}
The downside of this approach is that it only works one level deep (you can't override the bg-red-500!
class in the example above) and therefore makes a codebase hard to maintain as soon as multiple level composition is introduced.
default:
override variant
Using default:
to define which utlities can be overrideable is an idea from Adam Wathan https://x.com/adamwathan/status/1818041514863608141 but it wasn't implemented and might (likely) never be. This is more flexible but I don't see much value in the flexiblity here and it adds a lot of verbosity. Imo all classes applied to a base ui component should be overridable for one-offs.
Conclusion
Although @apply
is not recommended by the tailwind maintainers, I believe the @layer + @apply
approach is the clear winner here. Better performance but (oddly enough) also better DX. Still this seems to be unexplored territory for a copy/paste library so I'm hoping to gather some feedback to make sure I didn't miss anything. Looking forward to hear your thoughts, concerns, etc. 🫡