A refactor with CSS variables

The Women Who Design styling didn’t get a lot of love in its first few iterations, so I set aside time for a dedicated CSS refresh

Holy moly, it’s been a hot minute since the last time I wrote about Women Who Design back in June of 2017 and a looooot has changed under the hood in the last 18 months. There’s way too much to cover in one post, but, in short:

  1. Women Who Design is now a static site powered by Gatsby, hosted on Netlify
  2. Gatsby’s use of React and GraphQL replaced my need for Firebase
  3. Ditto for JQuery, Heroku, and Express.js
  4. The Twitter data now comes from a custom Gatsby source plugin that also saves the designer’s profile image – no more broken image files when someone changes their avatar!
  5. Gatsby’s Image component means lazy loading actually works and the page jumps less as the profile images load in
  6. Netlify’s webhooks integration rebuilds the site automatically every night to refresh all the Twitter data
  7. THERE’S A JOB BOARD (?!?!), powered by Seeker, and the data comes from another custom Gatsby source plugin
  8. I’m trying out all the fun new CSS things – CSS Grid, CSS Modules and CSS variables

I’m so excited about all these new updates, improvements and features, but the one I want to write about first is CSS variables. (Actually, to be fair, most of the updates listed above happened six months ago, but the CSS variables stuff only happened last month so it’s top of mind).

CSS variables are so useful because, unlike Sass variables, they don’t need to compile so they can be redefined at any time. Need every instance of a component to have a different theme? Check. Need a component’s styles change to match the theme of its parent container? Check. What about responsive spacing? Check.

Anyway, I guess I’ve now officially been writing code long enough to feel excited about deleting code and wow, did CSS variables help me delete code.

Let’s dive in!

The profile theme variable

One of the fun Easter eggs in the site design is that each designer’s profile is themed to their chosen Twitter theme color. I designed the profiles so that the theme extends to each display name, supporting icon, Twitter bio link and profile button link.

A screenshot of four designer profiles with different theme colors.

Achieving this effect was a long, messy process that went like this:

  1. The custom Gatsby source Twitter plugin requests a designer’s profile data from the Twitter API
  2. The plugin saves the designer’s chosen hex value from the API response as a variable
  3. The plugin runs a script to search the designer’s Twitter bio and create anchor tags for any URLs, @handles, #hashtags and email addresses found
  4. The script adds an inline style to any new anchor tag that applies the color of profile hex variable saved above
  5. The plugin “saves” the designer’s newly modified Twitter profile data to make it available through GraphQL (Gatsby calls this creating a node)
  6. When the plugin finishes, the site’s index.js file runs a GraphQL query for all of the profile data generated by the plugin
  7. The index.js file maps over each designer’s data and passes it to the profile.js component through specific props
  8. The profile.js component adds inline styles to color all the themed elements using the hex value prop

So, why refactor?

When I started learning web development, “Hey, the thing I wrote works!” was good enough for me. Then my thinking shifted to “Do I understand why the thing I wrote actually works the way it does?” Now that I’m a couple of years in, I’m trying to think more about writing stuff that behaves predictably: “Will this thing I wrote make sense to somebody else if they need to read or modify it?”

Which is basically what happened here. To get the theming working, I repeatedly set inline styles in different files at two different stages of the build process. I didn’t even realize how annoying this was until I shared the project code with someone a few weeks ago and, when asked about unsetting it, I realized they’d have to delete or change ~15 different lines of code to actually do it.

Separately, I’ve been thinking about open sourcing the Gatsby Twitter plugin, where the Twitter bio links get created and themed. Turning plain URL strings into actual anchor tags might be okay, but does adding opinionated color styling to specific HTML elements count as predictable output? Definitely not. You want plugins to do the one thing they set out to do and leave the rest as unopinionated as possible.

Finally, it was clear that the focus states throughout the site were pretty lacking and I wanted to give them an upgrade. Since you can’t style focus states using inline styles, I originally left them untouched, which looked like this:

A gif of the site's original focus states, which looked sloppy.

The default browser focus state got the job done but the padding was wonky and the CSS Grid layout was causing the outline around the buttons to be cut off.

Since CSS variables are just regular ole CSS, I realized I could create a profile theme CSS variable, use it throughout the rest of the component’s CSS (:focus pseudo-classes and all), then redefine the variable’s value with an inline style just once per designer.

The profile component

The first file I refactored was the profile.js component file, which receives the designer’s Twitter profile color through the hex value prop. For the purposes of this post, I’ve removed all markup and styling that isn’t relevant to CSS variables from the code snippets.

Here’s a look at all the instances of inline styles in that file:

profile.js
// Note: I'm using Sass + CSS Modules for styling

import styles from "./profile.module.scss";

const Profile = props => {
  return (
    <div className={styles.profile}>
      <h2 className={styles.name} style={{ color: props.hex }}>
        {props.name}
      </h2>
      <p className={styles.location}>
        <LocationIcon fill={props.hex} /> {props.location}
      </p>
      <LinkIcon fill={props.hex} />
      <a href={props.expandedUrl} className={styles.url}>
        {props.displayUrl}
      </a>
      <p
        className={styles.description}
        dangerouslySetInnerHTML={{ __html: props.description }}
      />
      <a
        href={`https://twitter.com/${props.handle}`}
        className={styles.linkButton}
        style={{ backgroundColor: props.hex }}
      >
        Twitter
      </a>
    </div>
  );
};

I started the refactor by defining a default --theme-color CSS variable on the .profile class, then setting it to color the user’s display name and profile link “button.”

profile.module.scss
.profile {
  --theme-color: black;
}

.name {
  color: var(--theme-color);
}

.linkButton {
  color: white;
  background-color: var(--theme-color);
}

Then I went back to the .profile class and used the --theme-color variable to color and set :focus pseudo-classes on all the anchor tags.

.profile {
  --theme-color: black;
  a {    text-decoration: none;    color: var(--profile-theme-color);    &:focus {      outline: none;      color: white;      background-color: var(--theme-color);      box-shadow: var(--theme-color) 0px 0px 0px 1px;    }  }}

Then I targeted the profile’s svg tags to style the location and website icons.

.profile {
  /* ... */
  svg {    fill: var(--theme-color);  }}

Then I added the focus for the .linkButton.

.linkButton {
  color: white;
  background-color: var(--theme-color);
  &:focus {    box-shadow: inset var(--theme-color) 0px 0px 0px 1px, inset white 0px 0px        0px 2px;  }}

Then I removed all the original inline styles and added a single declaration back in to theme the variable on the .profile container:

profile.js
const Profile = props => {
  return (
    <div style={{ "--theme-color": props.hex }} className={styles.profile}>      // ...
    </div>
  );
};

That change resulted in this much-improved tabbing experience:

gif test

The source plugin

Then I turned my attention to the Gatsby source Twitter plugin, which created and styled the Twitter bio anchor links (steps 1-5 above). That section of code had 6 specific profileColor references and looked like this:

gatsby-node.js
let description = profile.description;
let descriptionUrls = profile.entities.description.urls;
let profileColor = profile.profile_link_color;

if (descriptionUrls.length === undefined) {
} else {
  for (var i = 0; i < descriptionUrls.length; ++i) {
    description = description.replace(
      descriptionUrls[i].url,
      `<a href="${descriptionUrls[i].url}" 
      style="color: #${profileColor}">
          ${descriptionUrls[i].display_url}
      </a>`
    );
  }
}

description = Autolinker.link(description, {
  mention: "twitter",
  hashtag: "twitter",
  replaceFn: function(match) {
    switch (match.getType()) {
      case "url":
        var tag = match.buildTag();
        tag.setAttr("style", "color: #" + profileColor);
        return tag;

      case "mention":
        var mention = match.getMention();
        return `<a href="https://twitter.com/${mention}"  
          style="color: #${profileColor}">
            @${mention}
          </a>`;
      case "email":
        var email = match.getEmail();
        return `<a href="mailto:"${email}"  
          style="color: #${profileColor}">
            ${email}
          </a>`;
      case "hashtag":
        var hashtag = match.getHashtag();
        return `<a href="https://twitter.com/hashtag/${hashtag}"  
          style="color: #${profileColor}">
            #${hashtag}
          </a>`;
    }
  }
});
return description;

Since the CSS I wrote earlier targeted anchor tags in the .profile class, the inline styles here became unneccessary.

I deleted them from the script, simplifying the file considerably and making the profile component itself the source of all profile styling. In doing so, it should be easier and more predictable to update styles in the future.

The page theme variables

Next up I tackled page level theming. In the previous example, I applied a theme to the contents of a component. But for page level theming, I needed to theme the contents of a component based on the color scheme of its parent container.

The navigation is a good example of this. If the navigation theme is “light”, it means the navigation is sitting in a white container and should have a gray logo and gray links to stand out. If the theme is dark, it means the navigation is sitting in a gray container and should have a white logo and links to stand out.

Of course, the initial visible styles were just part of the puzzle. Like the designer profiles, I also wanted to fix the focus states in other parts of the site.

The nav component

Here’s what the JSX looked like at the start of the CSS variables refactor:

nav.js
const Nav = props => {
  const linkColor =
    props.theme === "light" ? styles.linkGray : styles.linkWhite;
  return (
    <nav
      className={
        props.theme === "light" ? styles.containerWhite : styles.containerGray
      }
    >
      <Link to="/">
        <Logo
          className={styles.logo}
          fill={props.theme === "light" ? "rgba(38, 38, 38, 1)" : "#fff"}
        />
      </Link>
      <Link to="/about" className={linkColor}>
        About
      </Link>
      <Link to="/nominate" className={linkColor}>
        Nominate
      </Link>
      <Link to="/jobs" className={linkColor}>
        Jobs
      </Link>
    </nav>
  );
};

So, where’s the room for improvement?

  • I wrote three almost identical ternary operators to check if the theme is light or dark, then apply conditional styling
  • Since the majority of the site’s pages have the light theme, the nav should have the light theme by default and the dark theme should be the override
  • Why Did I Let a Random RGBA Value Hang Out in My Logo SVG

Even worse, the CSS looked like this:

nav.module.scss
.containerGray {
  color: white;
}

.containerWhite {
  color: $gray;
}

.linkGray {
  color: $gray;
}

.linkWhite {
  color: white;
}

Yikes. That’s a lot of unnecessary repetition.

In my global styles file, I defined a --text variable and a --background variable. Again, since most of the site has a white background with dark gray text, I used those as the defaults.

global.scss
:root {
  --gray: rgba(30, 30, 30, 1);
  --text: var(--gray);
  --background: white;
}

Then I revisited the nav’s container stylings. Instead of having separate containerGray and containerWhite classes, I used a single container class where the text is --text colored and the background is --background colored. Simple enough.

nav.module.scss
.container {
  color: var(--text);
  background-color: var(--background);
}

I set the logo fill to match the --text color of the container, making the theme check in the SVG markup unnecessary. (To be fair, using fill: currentColor would have worked just as well here.)

.logo {
  display: block;
  width: 150px;
  fill: var(--text);
}

Ditto for the links.

.link {
  color: var(--text);
}

Then I added in the focus states.

.link {
  color: var(--text);
  &:focus {    outline: none;    text-decoration: none;    box-shadow: var(--background) 0px 0px 0px 2px, var(--text) 0px 0px 0px 3px;  }}

Finally, I deleted the original ternaries, the extra linkColor variable and the inline fill styles on the logo. Now the component has much better defaults and a single check for theming:

nav.js
const Nav = props => {
  return (
    <nav
      className={styles.container}
      style={{
        "--background": props.theme === "dark" && "var(--gray)",
        "--text": props.theme === "dark" && "white"
      }}
    >
      <Link to="/">
        <Logo className={styles.logo} />
      </Link>
      <Link to="/about" className={styles.link}>
        About
      </Link>
      <Link to="/nominate" className={styles.link}>
        Nominate
      </Link>
      <Link to="/jobs" className={styles.link}>
        Jobs
      </Link>
    </nav>
  );
};

The filter checkbox component

Next, I wanted to add focus states to the checkboxes in the sidebar that filter the designers on display in the main section. On mobile, the filters have dark gray text with a white background and on desktop, the color scheme reverses.

sidebar

Structurally, the filter checkbox component is a list item that contains a checkbox input:

filter-checkbox.js
const filterCheckbox = props => {
  return (
    <li className={styles.item}>
      <input type="checkbox" className={styles.input} />
      <label htmlFor={props.id} className={styles.label}>
        <span className={styles.span}>{props.title}</span>
      </label>
    </li>
  );
};

I went into the outermost .container class and looked at the original CSS.

filterCheckbox.module.scss
.container {
  color: var(--gray);
  background-color: white;
  @media (min-width: $desktop) {
    background-color: var(--gray);
    color: white;
  }
}

Using the same approach as before, I changed the color to the --text color variable and the background to the --background variable.

.container {
  color: var(--text);
  background-color: var(--background);
}

Then, for within a media query, I redefined the value of the variable. On desktop, the background becomes gray and the text becomes white.

.container {
  color: var(--text);
  background-color: var(--background);
  @media (min-width: $desktop) {    --background: var(--gray);    --text: white;  }}

It was about this time that I realized that the CSS I wrote was causing the checkboxes to be skipped entirely by keyboard navigation because I had given the default input checkbox a class of display none.

.input {
  display: none;
}

As it turns out, it’s better to use absolute positioning and opacity to get the same effect and keep the functionality accessible to keyboard navigators.

.input {
  position: absolute;
  opacity: 0;
  z-index: -1;
  width: 1px;
  height: 1px;
}

Then I added the variables and focus state styles.

.item label:before {
  border: 1px solid var(--text);
}

.item input[type="checkbox"]:checked ~ label:before {
  background-color: var(--text);
}

.input:focus ~ label:before {
  box-shadow: 0 0 0 3px var(--background), 0 0 0 4px var(--text);
}

Since the filters appear in the .container, they now inherit the variable color changes set above in the media query.

Woohoo! After these changes, the tabbing experience improved to this:

A gif of the site's sidebar and navigation focus states.

The spacing variable

The last area that I wanted to refactor with CSS variables was the site’s spacing. There are a few places throughout the site that required the same spacing values to maintain a nice, even layout.

page spacing

On the homepage, that meant using the same value for the padding of the sidebar, the padding of the main section, and the vertical gutter between the profile columns.

I started by defining my spacing variable, which I called --page-shell-padding.

global.scss
:root {
  /* ... */
  --page-shell-padding: 24px;
}

Of course, desktop viewports have more screen to work with and I wanted the spacing to grow accordingly. Inside a media query, I gave the --page-shell-padding variable a larger value on desktop. In my opinion, media queries are some of the handiest places to use CSS variables.

:root {
  --page-shell-padding: 24px;
  @media (min-width: $desktop) {
    --page-shell-padding: 32px;
  }
}

Then I used it throughout the site like so:

index.module.scss
.main {
  /* ... */
  padding: var(--page-shell-padding);
}

.sidebar {
  /* ... */
  padding: var(--page-shell-padding);
}

.formSubmit {
  /* ... */
  margin: var(--page-shell-padding) 0;
}

.backButton {
  /* ... */
  margin-top: var(--page-shell-padding);
  padding-top: var(--page-shell-padding);
}

The variable became especially useful for creating spacing relationships when I needed just a bit more or less space than the value defined. For example, in the grid of profiles, I wanted the gap between the horizontal rows to be larger than the gap between the vertical columns. Using the CSS calc function, I multiplied the variable by 1.5 and got value that worked well.

.profileContainer {
  display: grid;
  grid-gap: calc(var(--page-shell-padding * 1.5)) var(--page-shell-padding);
}

The best part here is that the relationship stays the same as the variable changes across viewports without any additional media queries.

Takeaways

All in all, I’m happy with the progress I made in this CSS refactor. As always, there’s still a million more things that need fixing, but I can safely say that it’s much clearer and cleaner than the first two versions of the site.

My favorite parts were definitely giving the focus states proper attention and creating a more cohesive feel throughout the UI. Even as someone who works on design systems full-time, I was surprised by how many divergent styles and states I was able to introduce to the site when I wasn’t paying close attention to the CSS. The process of writing the focus state styles actually helped surface this more than anything else, and it led to me tidying up lots of other little inconsistencies.

I’m also still working to suss out the balance I want to strike between writing code that is neat and writing code that is clear. Though conventional wisdom states “do not repeat yourself,” I think repetition can be valuable if it makes it easier to predict what the code’s output will be.

There are a whole bunch of other Women Who Design announcements and updates to cover in the very near future, so I’ll leave it at that for now. It’s still nutty to me that this project is still alive and kicking almost two years later, so here’s to an exciting start to 2019!

Next →