There are many ways to write CSS. For the last couple of years, Tailwind has become a favorite way to do so for many people. I love Tailwind too and use it frequently in my projects. CSS-in-JS is also extensively used to style big-scale web apps and a library of components.
But recently vanilla CSS was receiving many updates, which made it very powerful and possess some unique (and useful) features that CSS frameworks don't have:
- CSS variables greatly solve the problem of theming and sharing configuration - no need to use JS for that.
:where()
selector makes customizing button styles easy:has()
selector to style based on the parent's statehsl()
to
But my favorite feature of modern CSS is easy responsiveness: Font sizes, spacing between components, and paddings can now become responsive with just a single line of code. No more need to write 4 media queries to make sure your website looks good on any device, instead, use max(), min() and clamp()
functions.
Today I want to explore how these features, with the help of PostCSS, affect the way we could create different components, starting from buttons:
First, let's define how our buttons will vary:
- We want to have 3 types of buttons: filled, outline, and invisible (or text) buttons.
- Moreover, we want 3 color options: default white/black, primary and danger colors and 2 size options: medium and small.
- We must also support different button states (hover, disabled, etc.) and light and dark themes.
Global styles
Before we proceed with Buttons, we need to inspect our design. In the real world, we will have our base and other shared colors defined in global.css
:
:root {
/* default colors */
/* text colors used as a base text color */
--color-text-base-light: hsl(221, 28%, 16%);
--color-text-base-dark: hsla(0, 0%, 100%, 0.9);
--color-text-base: var(--color-text-base-light);
/* background colors */
--color-bg-1-light: hsl(0, 0%, 100%);
--color-bg-2-light: hsl(225, 40%, 98%);
--color-bg-1-dark: hsl(220, 18%, 13%);
--color-bg-2-dark: hsl(220, 15%, 16%);
--color-bg-1: var(--color-bg-1-light);
--color-bg-2: var(--color-bg-2-light);
/* line colors */
--color-line-light: hsl(210, 14%, 83%);
--color-line-dark: hsl(219, 10%, 28%);
--color-line: var(--color-line-light);
/* primary colors */
--color-primary-dark: hsl(220, 96%, 78%);
--color-primary-light: hsl(220, 94%, 68%);
--color-primary: var(--color-primary-light);
--color-primary-hover-dark: hsl(220, 94%, 68%);
--color-primary-hover-light: hsl(212, 76%, 47%);
--color-primary-hover: var(--color-primary-hover-light);
/* danger */
--color-danger-light: hsl(355, 74%, 53%);
--color-danger-dark: hsl(355, 94%, 82%);
--color-danger: var(--color-danger-light);
--color-danger-hover-light: hsl(355, 67%, 44%);
--color-danger-hover-dark: hsl(355, 91%, 71%);
--color-danger-hover: var(--color-danger-hover-light);
}
As you might have noticed, we have 2 versions of each color and a third variable to hold the current theme's color. That is how we will support light and dark themes. You can learn more about building color schemas here. Later we will switch the colors to use the dark versions:
/* PostCSS's magic */
@custom-media --is-dark (prefers-color-scheme: dark);
@media (--is-dark) {
:root {
--color-text-base: var(--color-text-base-dark);
--color-bg-1: var(--color-bg-1-dark);
--color-bg-2: var(--color-bg-2-dark);
--color-line: var(--color-line-dark);
--color-primary: var(--color-primary-dark);
--color-primary-hover: var(--color-primary-hover-dark);
--color-danger: var(--color-danger-dark);
--color-danger-hover: var(--color-danger-hover-dark);
}
}
Styling buttons
We will start from the default filled buttons and introduce properties one by one:
:where(.button) {
--_text-light: var(--color-text-base-light);
--_text-dark: var(--color-text-base-dark);
--_text: var(--_text-light);
}
:where()
ensures having low specificity so the styles can be easily overridden. Let's add other properties:
:where(.button) {
...
--_bg-light: var(--color-bg-100-light);
--_bg-dark: var(--color-bg-100-dark);
--_bg: var(--_bg-light);
--_bg-hover-light: var(--color-bg-200-light);
--_bg-hover-dark: var(--color-bg-200-dark);
--_bg-hover: var(--_bg-hover-light);
--_border-light: var(--color-line-light);
--_border-dark: var(--color-line-dark);
--_border: var(--_border-light);
}
We defined internal both themes' variables for text, background, and border colors. Then we will apply these styles:
:where(.button) {
...
background: var(--_bg);
color: var(--_text);
border: 1px solid var(--_border);
}
Let's add other styles from the design to our buttons in a similar manner:
:where(.button) {
...
--_padding-inline: 1.75ch;
--_padding-block: .75ch;
--_border-radius: .5ch;
--_shadow-depth-light: 0 1px var(--_border-light);
--_shadow-depth-dark: 0 1px var(--_border-dark);
--_shadow-depth: var(--_shadow-depth-light);
--_transition-motion-reduce: ;
--_transition-motion-ok:
box-shadow 145ms ease,
background 145ms ease,
outline-offset 145ms ease;
--_transition: var(--_transition-motion-reduce);
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
font: inherit;
letter-spacing: inherit;
line-height: 1.5;
/* not defined before, default to 1 rem */
font-size: var(--_size, 1rem);
font-weight: 600;
display: inline-flex;
justify-content: center;
align-items: center;
text-align: center;
gap: 1ch;
padding-block: var(--_padding-block);
padding-inline: var(--_padding-inline);
border-radius: var(--_border-radius);
box-shadow: var(--_shadow-depth);
transition: var(--_transition);
}
Using ch
to size paddings, gaps, and corner radius is an alternative way to make the button responsive to the font size. Therefore, creating different sizes of the button will only require the following:
:where(.button.sm) {
--_size: 0.75rem;
}
Don't forget to add styles for the hovered, pressed, focused and disabled states:
/* hover */
:where(.button:not([disabled]):hover) {
background: var(--_bg-hover);
}
/* active */
:where(.button:not([disabled]):active) {
transform: translateY(1px);
}
/* focus */
:where(.button):where(:not(:active)):focus-visible {
outline-offset: 5px;
}
/* disabled */
:where(.button[disabled]) {
opacity: 60%;
cursor: not-allowed;
box-shadow: none;
}
Adding colors
Similarly, extending the default color scheme to a different one could be done by changing the CSS variables according to our design:
:where(.button[data-color='primary']) {
--_text: var(--color-text-base-dark);
--_bg: var(--color-primary);
--_bg-hover: var(--color-primary-hover);
--_border: var(--color-primary-hover);
}
:where(.button[data-color='danger']) {
--_text: var(--color-text-base-dark);
--_bg: var(--color-danger);
--_bg-hover: var(--color-danger-hover);
--_border: var(--color-danger-hover);
}
Outline variant
Outline buttons have transparent backgrounds and text color matching the border color while they are resting and if we hover it, they become filled:
:where(.button[data-variant='outline']) {
--_bg: transparent;
}
:where(.button[data-variant='outline']:not([data-color='default']):not(:hover)) {
--_text: var(--_bg-hover);
}
To make the text color change, the second selector targets all the outline buttons that are not being hovered.
Due to the specifics of our design, the default button should not follow this rule. That's why it also has :not([data-color="default"])
specificity added.
Final touches
Lastly, let's add support for the dark theme and reduced motion preference:
/* toggle dark mode */
@media (--is-dark) {
:where(.button) {
--_text: var(--_text-dark);
--_bg: var(--_bg-dark);
--_bg-hover: var(--_bg-hover-dark);
--_border: var(--_border-dark);
--_shadow-depth: var(--_shadow-depth-dark);
}
}
/* toggle reduced motion */
@media (prefers-reduced-motion: no-preference) {
:where(.button) {
--_transition: var(--_transition-motion-ok);
}
}
As you can see, CSS supercharged with PostCSS provides great tools to create a highly customizable library of components. Do you think that CSS will make other frameworks obsolete just like how JavaScript became more useful than jQuery?