Keeping CSS Organised

15 Oct 2017

I've seen CSS get out of control. It's Real Programming, and it's worth putting some time into thinking about how to structure it upfront.

There are lots of ways to do it. Here's, generally, how I do mine.

My two goals are:

  • Lack of repetition. Miss this, and the site eventually becomes an inconsistent visual mess, because we'll change something in one place instead of both of them.
  • Lack of side effects. CSS is hard to test automatically. If the code has non-obvious side effects, then we'll get surprise breakage, or we'll spend a lot of time manually testing, or (perhaps most insidiously) we won't trust it and the code will bloat as we extend rather than re-use.

These goals are, to some extent, in conflict.

Framework

Right now I'm generally using Bootstrap. It's heavy, but it's well-known and therefore maintainable.

Compiler

SCSS - powerful and Rails default.

Naming Conventions

I like BEM.

The short version:

  • class names are block__element--modifiername--modifiervalue
  • use kebab-case
  • eg: contact-page__email-button--theme--ocean
  • A Block is meaningful on its own. An Element is scoped to a Block. So contact-page__email-button is completely unrelated to quote-page__email-button.
  • Style explicitly. Use classes everywhere. No tag names, no ids.
  • Don't nest elements. Instead connect grandchild elements to the parent block. Eg don't do contact-page__email-form__submit-button - instead do contact-page__submit-button. Or if that doesn't make sense, make a new block (but don't show the nesting in block names).

Keeping BEM DRY With @extend

While BEM does a good job of making styling obvious, the problem is avoiding repetition.

I see styling as a three-step mapping:

"Meaning of information" --< "Type of information" --- "Visual style"

For example:

  • Meaning: "Blogpost's author"
  • Type: "Secondary, contextual data"
  • Style: "9pt grey text"

Many different Meanings might have the same Type, here - on the same site, the Blogpost's Date, a Category's Description, and so on might all need to look consistent.

Naming them by Visual Styles would give us:

<div class='light-grey-text'>Gwyn Morfey</div>

That's only one refactor away from ending up as:

.light-grey-text {color: red;}

So we're not doing that.

Naming them by Types would be more sensible:

<div class='secondary-info'>Author: Gwyn Morfey</div>
<div class='secondary-info'>Date: 14 Oct 2017</div>
...
.secondary-info {
    font-size: 9pt;
    color: gray;
}

But BEM doesn't allow this. Using Meanings gives us:

<div class='author'>Author: Gwyn Morfey</div>
<div class='date'>Date: 14 Oct 2017</div>
...
.author {
    color:gray;
    font-size: 9pt;
}
.date {
    color:gray;
    font-size: 9pt;
}

This is repetitive, and it'll get out of sync. If we fixed it using an OR selector in the CSS definition, we'd get:

.author, .date {
    color:gray;
    font-size: 9pt;
}

Which is better, but can lead to non-obvious results if we also want to have some styles that apply only to .author - we'll end up with multiple sets of rules that apply to one class.

I handle this by separately defining my Types in a utility file, and then using SCSS's @extend functionality to bring them in to the Meaning-based classes:

.secondary-info {
    color:gray;
    font-size: 9pt;
}

.author {
    @extend .secondary-info;
}
.date {
    @extend .secondary-info;
}

Typography Classes

The starting point is total consistency - the heading hierarchy should be clear, with h2 looking the same everywhere across the site. But there may be places (eg a sidebar) where h2 is semantically correct, but needs to look different.

I agree that using .h1, .h2 etc for typography classes could be better: <h1 class='h2'> is not a bug but it looks like one.

.t1, .t2, .t3 makes sense to me, but in small projects where I never need to override headings, I think it also makes sense to just use h1 directly (breaking BEM, but in a readable way).

SCSS Nesting

I generally don't use it (see Don't Be Clever). I currently make an exception by nesting elements inside blocks - although the generated CSS does have different specificity, it doesn't break anything and it makes the SCSS easier to read. For example:

.about-me {
    max-width:600px;
    .about-me__name {
        font-weight:bold;
    }
}

Don't Be Clever

CSS is not code golf. It's better to be explicit, even if that means being verbose. BEM implements this as "always select on classNames, not element names", let alone on esoteric stuff like parents, siblings, children, roles, &c...

(Unless The Alternative Is Hopelessly Verbose)

... unless the alternative is worse. For example, on this site, I use larger bottom margins on <p tags inside article bodies, to make large blocks of text easier to read. A strictly BEM approach would be:

<div class='note'>
    <p class='note__para'>
        A paragraph
    </p>

With CSS like:

.note_para {
    margin-bottom: 1.1em;
}

But doing that would involve hacking the markdown renderer to add that class to every P (and later to Li, H2, &c). In this case it's better to carve out an exception, and do:

.note__body { /* Yup, breaking BEM a bit here */
    li, p { 
        margin-bottom:1.1em;
    }
    h2,h3,h4 {
        margin-top:1.7em;
        margin-bottom:1em;
    }

Dropping The Block Name When A Modifier Is Present

I do it, because @extend makes it easy. For example, I do:

<input class='button--yellow'>

.button--yellow {
    @extend .button
    color: #FF0;
}

Rather than:

<input class='button button--yellow'>

Block Names, Partials, and Cases

The BEM standard uses kebab-case for block names. Rails prefers snake_case, because foo-bar isn't a valid variable name in ruby. Where I'm using a partial for a block, I keep the name consistent but map the naming style, so _note_summary.html.erb would contain:

<div class='note-summary'>
    <h1><%= note_summary.title %></h1>
</div>

Other Nifty Tricks

SCSS's @mixin system is awesomely powerful, including parameters, conditionals and even an equivalent to Ruby's blocks. There's a good overview here.

Documentation

http://sassdoc.com/

npm install -g sassdoc
sassdoc app/assets/stylesheets/ && open sassdoc/index.html 

The docs are extensive, but the short version is: describe things with comments beginning with /// . Eg:

/// Typography style. Used for text that's secondary, like 
/// metadata (author name, date), sidebar info, etc.
.secondary-info {
    font-size:70%;
    color:$gray;
}

Rejected Options

When making decisions, I always like to select the best option, and reject the others, and record why. This saves time later, since I'm not tempted to switch back later on, having forgotten why the other option is worse.

SMACSS

For naming conventions, I looked at a number of others. Here's a good overview. SMACSS seems to have some traction, but according to one example:

// CSS
.questionCard {
  position: relative;
  margin-top: $ scale1;
  padding: $ scale2;
  background-color: #fff;
  box-shadow: $ boxShadow-2;
}
// html
<div class="questionCard">...

becomes:

// CSS
.u-relative {
  position: relative;
}
.u-mt1 {
  margin-top: $ scale1;
}
.u-p2 {
  padding: $ scale2;
}
.v-bg-white {
  background-color: #fff;
}
.b-bs2 {
  box-shadow: $ boxShadow-2;
}
// HTML
<div class="u-relative u-mt1 u-p2 v-bg-white b-bs2">...

At least to my eye, that's worse; it's mixed the presentation into the HTML, and it's more verbose and harder to read. Perhaps it's intended for different types of project.

Namespacing

So far I've found this to be overkill, although I can see the value of separating 'position' and 'appearance'. I do use classnames beginning 'js-' for, and exclusively for, javascript hooks.

Inline CSS in Components

There's an argument that a (Vuejs or React, or similar) Component should incorporate its own CSS along with HTML and JS. I'm not entirely convinced by this yet, although I could see it working if used with SCSS variables, @mixins and @extend. Currently my standard is not to do this.

Putting It All Together

Gemfile:

gem 'bootstrap-sass'

app/assets/stylesheets/application.scss:

@import url("https://fonts.googleapis.com/css?family=Arvo");
@import "_variables"; /* Must do this before loading bootstrap */
@import "bootstrap-sprockets";
@import "bootstrap";
@import "_utilities";
@import "_fixes";
@import "blocks";

app/assets/stylesheets/_variables.scss:

$page-bg-color: #fcfcfc;
$text-color: #222;
$font-family-base: Arvo, serif;
$font-size-base: 20px;

$font-size-h1: $font-size-base * 2.4;
$font-size-h2: $font-size-base * 1.5;
$font-size-h3: $font-size-base * 1.25;
$font-size-h4: $font-size-base * 1;
$font-size-h5: $font-size-base * 1;
$font-size-h6: $font-size-base * 1;


$brand-primary: #303535;
$brand-verylight: lighten($brand-primary, 50%);

$navbar-inverse-bg: darken($brand-primary, 25%);
$navbar-inverse-color: #FFF;
$navbar-inverse-link-color: #FFF;

app/assets/stylesheets/_utilities.scss:

/* These are mixed in to blocks with @extend, not invoked directly */
.secondary-info {
    font-size:70%;
    color:$gray;
}

app/assets/stylesheets/blocks.scss:

/* Wrapper ----------------------- */
.wrapper-content {
    max-width: 800px;
    margin-left:auto;
    margin-right:auto;
}


/* Blocks used only on the homepage ----- */

.about-me {
    @extend .secondary-info;
    max-width:600px;
    .about-me__name {
        font-weight:bold;
    }
}

.note-summary {
    padding-bottom: 0.5em;
    padding-top: 0.5em;
    padding-left: 0.2em;
    padding-right: 0.2em;
    border-bottom: 1px dotted $gray-light;
    min-height: 6em;
    .note-summary__date {
        @extend .secondary-info;
    }
}

.category-link {
    @extend .note-summary;
    background-color: $brand-verylight;
    min-height: inherit;
    text-align:center;
    .category-link__link {
    }
}

/* Blocks used only on note view page ------- */

.note {
    .note__title {
    }   
    .note__date {
        @extend .secondary-info;
        text-align:right;
        margin-bottom:1em;
    }
    .note__body { /* Yup, breaking BEM a bit here */
        li, p { 
            margin-bottom:1.1em;
        }
        h2,h3,h4 {
            margin-top:1.7em;
            margin-bottom:1em;
        }
        a {
            text-decoration: underline;
        }

    }
}

.more-notes {
    @extend .secondary-info;
    text-align: center;
    margin-top:4em;
    padding-top:1em;
    margin-bottom:2em;
    border-top:1px solid $gray;
}