Global and local styling
In the Composition section we covered how small, nonlexical components for layout can be used to create larger composites, but not all styles within an efficient and consistent CSS-based design system should be strictly component based. This section will contextualize layout components in a larger system that includes global styles.
What are global styles?
When people talk about the global nature of CSS, they can mean one of a few different things. They may be referring to rules on the :root
or <body>
elements that are inherited globally (with just a few exceptions).
:root {
/* ↓ Now (almost) all elements display a sans-serif font */
font-family: sans-serif;
}
Alternatively, they may mean using the unqualified *
selector to style all elements directly.
* {
/* ↓ Now literally all elements display a sans-serif font */
font-family: sans-serif;
}
Element selectors are more specific, and only target the elements they name. But they are still “global” because they can reach those elements wherever they are situated.
p {
/* ↓ Wherever you put a paragraph, it’ll be sans-serif */
font-family: sans-serif;
}
A liberal use of element selectors is the hallmark of a comprehensive design system. Element selectors take care of generic atoms such as headings, paragraphs, links, and buttons. Unlike when using classes (see below), element selectors can target the arbitrary, unattributed content produced by WYSIWYG editors and markdown.
The layouts of Every Layout do not explore or prescribe styles for simple elements; that is for you to decide. It is the imbrication of simple elements into composite layouts that we are interested in here.
Finally, class-based styles, once defined, can be adhered to any HTML element, anywhere in a document. These are more portable and composable than element styles, but require the author to affect the markup directly.
.sans-serif {
font-family: sans-serif;
}
<div class="sans-serif">...</div>
<small class="sans-serif">...</small>
<h2 class="sans-serif">...</h2>
It should be appreciated how important it is to leverage the global reach of CSS rules. CSS itself exists to enable the styling of HTML globally, and by category, rather than element-by-element. When used as intended, it is the most efficient way to create any kind of layout or aesthetic on the web. Where global styling techniques (such as the ones above) are used appropriately, it’s much easier to separate branding/aesthetic from layout, and treat the two as separate concerns.
Utility classes
As we already stated, classes differ from the other global styling methods in terms of their portability: you can use classes between different HTML elements and their types. This allows us to diverge from inherited, universal, and element styles globally.
For example, all of our <h2>
elements may be styled, by default, with a 2.25rem
font-size
:
h2 {
font-size: 2.25rem;
}
h3 {
font-size: 1.75rem;
}
However, there may be a specific cases where we want that font-size
to be diminished slightly (perhaps horizontal space is at a premium, or the heading is somewhere where it should have less visual affordance). If we were to switch to an <h3>
element to affect this visual change, we would make a nonsense of the document structure.
Instead, we could build a more complex selector pertaining to the smaller <h2>
’s context:
.sidebar h2 {
font-size: 1.75rem;
}
While this is better than messing up the document structure, I've made the mistake of not taking the whole emerging system into consideration: We've solved the problem for a specific element, in a specific context, when we should be solving the general problem (needing to adjust font-size
) for any element in any context. This is where utility classes come in.
/* ↓ Backslash to escape the colon */
.font-size\:base {
font-size: 1rem;
}
.font-size\:biggish {
font-size: 1.75rem;
}
.font-size\:big {
font-size: 2.25rem;
}
We use a very on the nose naming convention, which emulates CSS declaration structure: property-name:value
. This helps with recollection of utility class names, especially where the value echos the actual value, like .text-align:center
.
Sharing values between elements and utilities is a job for custom properties. Note that we’ve made the custom properties themselves globally available by attaching them to the :root
(<html>
) element:
:root {
--font-size-base: 1rem;
--font-size-biggish: 1.75rem;
--font-size-big: 2.25rem;
}
/* elements */
h3 {
font-size: var(--font-size-biggish);
}
h2 {
font-size: var(--font-size-big);
}
/* utilities */
.font-size\:base {
font-size: var(--font-size-base) !important;
}
.font-size\:biggish {
font-size: var(--font-size-biggish) !important;
}
.font-size\:big {
font-size: var(--font-size-big) !important;
}
Each utility class has an !important
suffix to max out its specificity. Utility classes are for final adjustments, and should not be overridden by anything that comes before them.
The values in the previous example are just for illustration. For consistency across the design, your sizes should probably be derived from a modular scale. See Modular scale for more.
Too many utility classes
One thing we highly recommend is not including utility classes until you need them. You don’t want to send users any unused or redundant data. For this project, we maintain a helpers.css
file and add utilities as we find ourselves reaching for them. If we find the text-align:center
class isn’t taking effect, we must not have added it in the CSS yet — so we put it in our helpers.css
file for present and future use.
In a utility first approach to CSS, inherited, universal, and element styles are not really leveraged at all. Instead, combinations of individual styles are applied on a case-by-case basis to individual and independent elements. Using the Tailwind utility-first framework this might result—as exemplified by Tailwind's own documentation—in class values like the following:
class="rounded-lg px-4 md:px-5 xl:px-4 py-3 md:py-4 xl:py-3 bg-teal-500 hover:bg-teal-600 md:text-lg xl:text-base text-white font-semibold leading-tight shadow-md"
There may be reasons, under specific circumstances, why one might want to go this way. Perhaps there is a great deal of detail and disparity in the visual design that benefits from having this kind of granular control, or perhaps you want to prototype something quickly without context switching between CSS and HTML. Every Layout's approach assumes you want to create robustness and consistency with the minimum of manual intervention. Hence, the concepts and techniques laid out here leverage axioms, primitives, and the styling algorithms that extrapolate from them instead.
Local or 'scoped' styles
Notably, the id
attribute/property (for reasons of accessibility, most importantly) can only be used on one HTML element per document. Styling via the id
selector is therefore limited to one instance.
#unique {
/* ↓ Only styles id="unique" */
font-family: sans-serif;
}
The id
selector has a very high specificity because it’s assumed unique styles are intended to override competing generic styles in all cases.
Of course, there’s nothing more “local” or instance specific than applying styles directly to elements using the style
attribute/property:
<p style="font-family: sans-serif">...</p>
The only remaining standard for localizing styles is within Shadow DOM. By making an element a shadowRoot
, one can use low-specificity selectors that only affect elements inside that parent.
const elem = document.querySelector('div');
const shadowRoot = elem.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>
p {
/* ↓ Only styles <p>s inside the element’s Shadow DOM */
font-family: sans-serif;
}
</style>
<p>A sans-serif paragraph</p>
`;
Drawbacks
The id
selector, inline styles, and Shadow DOM all have drawbacks:
id
selectors: Many find the high specificity to cause systemic issues. There’s also the necessity of coming up with theid
’s name in each case. Dynamically generating a unique string is often preferable.- Inline styles: A maintenance nightmare, which is the reason CSS was antidotally conceived in the first place.
- Shadow DOM: Not only are styles prevented from “leaking out” of the Shadow DOM root, but (most) styles are not permitted to get in either — meaning you can no longer leverage global styling.
What we need is a way to simultaneously leverage global styling, but apply local, instance-specific styles in a controlled fashion.
Primitives and props
As set out in Composition, the main focus of Every Layout is on the simple layout primitives that help to arrange elements/boxes together. These are the primary tools for eliciting layout and take their place between generic global styles and utilities.
- Universal (including inherited) styles
- Layout primitives
- Utility classes
Manifested as reusable components, using the custom elements specification, these layouts can be used globally. But unique configurations of these layouts are possible using props (properties).
Interoperability
Custom elements are used in place of React, Preact, or Vue components (which all also use props) in Every Layout because they are native, and can be used across different web application frameworks. Each layout also comes with a code generator to produce just the CSS code needed to achieve the layout. You can use this to create a Vue-specific layout primitive (for example) instead.
Defaults
Each layout component has an accompanying stylesheet that defines its basic and default styles. For example, the Stack stylesheet (Stack.css
) looks like the following.
stack-l {
display: block;
}
stack-l > * + * {
margin-top: var(--s1);
}
A few things to note:
- The
display: block
declaration is necessary since custom elements render as inline elements by default. See Boxes for more information on block and inline element behavior. - The
margin-top
value is what makes the Stack a stack: it inserts margin between a vertical stack of elements. By default, that margin value matches the first point on our modular scale:--s1
. - The
*
selector applies to any element, but our use of*
is qualified to match any successive child element of a<stack-l>
(note the adjacent sibling combinator). The layout primitive is a composition in abstract, and should not prescribe the content, so I use*
to match any child element types given to it.
In the custom element definition itself, we apply the default value to the space
property:
get space() {
return this.getAttribute('space') || 'var(--s1)';
}
Each Every Layout custom element builds an embedded stylesheet based on the instance’s property values. That is, this…
<stack-l space="var(--s3)">
<div>...</div>
<div>...</div>
<div>...</div>
</stack-l>
…would become this…
<stack-l data-i="Stack-var(--s3)" space="var(--s3)">
<div>...</div>
<div>...</div>
<div>...</div>
</stack-l>
… and would generate this:
<style id="Stack-var(--s3)">
[data-i='Stack-var(--s3)'] > * + * {
margin-top: var(--s3);
}
</style>
However—and this part is important—the Stack-var(--s3)
string only represents a unique configuration for a layout, not a unique instance. One id="Stack-var(--s3)"
<style>
element is used to serve all instances of <stack-l>
sharing the configuration represented by the Stack-var(--s3)
string. Between instances of the same configuration, the only thing that really differentiates them is their content.
While each item of content within a web page should generally offer unique information, the look and feel should be consistent and regular, using familiar and repeated patterns, motifs, and arrangements. Leveraging global styles along with controlled layout configurations results in consistency and cohesion, and with minimal code.