Automating styles with data-slot
As we work on Flare’s performance monitoring feature, we’ve adopted a method to streamline our component architecture, making our UI more adaptable and maintainable. We derived this approach from Adam Wathan, who gave an insightful presentation titled "Designing a Component Library" at Laracon US 2024. Although it’s been five months since the talk, there were some awesome takeaways. In his presentation, Adam showcased techniques for building flexible and maintainable UI components using Tailwind CSS. One of the key ideas he explored was the use of the data-slot
attribute to improve component structure and reusability.
The data-slot
attribute allows developers to define placeholders within components that can be dynamically filled with content, reducing the need for excessive props or complex conditional rendering. By selecting elements based on data-slot
, we can inject content where needed while keeping components clean and flexible.
A Practical Example: Button Component with data-slot
Let’s say we have a button component that simply renders its children. We assign a fixed height of 40 pixels to the button and set padding of 12 pixels on the left and right.
export function Button({ children }) {
return (
<button className='flex items-center justify-center gap-3 rounded bg-purple-500 text-white text-semibold px-3 h-[40px]'>
{children}
</button>
);
}
Now, based on this Button
component, we can create a button with both text and an icon by passing them as children:
export function App() {
return (
<Button>
Performance monitoring
<Icon />
</Button>
);
}
But what if we want a button with just an icon and no text? Simply removing the text does the job, but now the button's dimensions become inconsistent. Its width and height vary because of the padding, and ideally, we want it to be a square when only an icon is present.
To fix this, we could use the aspect-square
class, but how does the Button
component know when to apply it?
The Old Approach: Using a Prop
Before discovering data-slot
, we handled this scenario by adding an explicit icon
prop to the Button
component:
export function App() {
return (
<Button icon>
<Icon />
</Button>
);
}
This worked, but it was tedious. Every time we used an icon-only button, we had to remember to pass the icon
prop, something easy to forget, leading to inconsistencies. Ideally, the button should automatically adjust based on its children.
The data-slot
Solution
Instead of adding a prop, we can mark the Icon
component with data-slot="icon"
:
export function Icon() {
return (
<span data-slot="icon" />
);
}
Then, we modify the Button
component to detect when it only contains an icon:
export function Button({ children }) {
return (
<button className='flex items-center justify-center gap-3 rounded bg-purple-500 text-white text-semibold px-3 h-[40px] [&:has(>[data-slot=icon]:only-child)]:aspect-square'>
{children}
</button>
);
}
Now, the aspect-square
class is automatically applied when the button contains only an Icon
component!
Breaking Down the Class Selector
Let’s analyze this newly added class:
[&:has(>[data-slot=icon]:only-child)]:aspect-square
This means:
&
: Selects the current element (thebutton
).:has()
: A pseudo-selector that checks if the parent has a specific child.>[data-slot=icon]:only-child
: Selects a direct child with the attributedata-slot="icon"
that has no siblings.
Extending the Solution: Handling Padding
We can go a step further and remove padding when the button has only an icon:
export function Button({ children }) {
return (
<button className='flex items-center justify-center gap-3 rounded bg-purple-500 text-white text-semibold h-[40px] [&:has(>[data-slot=icon]:only-child)]:aspect-square [&:not(:has(>[data-slot=icon]:only-child))]:px-3'>
{children}
</button>
);
}
This means:
- If the button has only an icon, it becomes a square and loses its padding.
- If the button has other children, it retains
px-3
(left and right padding).
Why does this approach rock?
This approach eliminates the need for extra props, allowing the button to adapt automatically and reducing unnecessary API surface. The JSX remains cleaner since there’s no need to manually specify button types or styles. With CSS handling the logic, components become more declarative and easier to maintain.
A Potential Downside: Class Complexity
While this approach is powerful, it can result in long, hard-to-read class names. To improve readability, we use the clsx
library to split rules onto separate lines:
clsx(
'flex items-center justify-center gap-3 h-[40px]',
'rounded bg-purple-500 text-white text-semibold',
'[&:not(:has(>[data-slot=icon]:only-child))]:px-3',
'[&:has(>[data-slot=icon]:only-child)]:aspect-square',
)
This makes the styling more manageable while keeping the logic intact. Although, we'll admit, it can still get out of hand.
Final thoughts
We’ve used the data-slot
attribute in several components to dynamically adjust spacing, layout, and behavior based on their children. The :has()
selector has been a game-changer. Without it, this level of flexibility wouldn’t be possible.
This was a relatively simple use case, but Adam Wathan goes much deeper in his talk. He demonstrates how form components can adapt based on their children and even tackles nerve-racking dropdown component issues.
If you’re interested in learning more, we highly recommend watching his talk. Hopefully, this inspires you as much as it did for us!