Skip to content
academia.sh

Lesson 22 / 23

The Utility Class Approach

Constraint-based style systems; single-declaration classes taking a value from a closed scale, the set growing independent of component count, repetition moving from the stylesheet into the document, and where the approach breaks.

Contents

Every approach up to this point has shared the same assumption: each component has its own rules, and the stylesheet grows component by component. There is an approach that argues the opposite.

The idea is this: no component-specific rule is ever written. The document is built out of predefined, single-declaration classes. Box design happens not in the stylesheet, but in the document.

The Constraint Idea

A utility class is a class that writes a single declaration and takes its value from a closed scale. Its name announces what it does: p-1 gives a padding value, gap-0 gives a gap value, bg-surface gives a surface color.

The approach’s name evokes brevity, but its real justification is not brevity, it is constraint. The scale is closed: if there are five values for padding, there is no way to write a sixth. Design consistency stops being a review question; the system itself makes an arbitrary value impossible.

The second consequence concerns naming. In the utility class approach, no new name is generated; the names in the set are known from the start. The previous three lessons’ problems — name collision, ownership, deletability — do not disappear, they become moot: the class belongs to no one.

The Set’s Bounded Growth

// utility.mjs — the utility class set's bounded growth, compared against component notation
// Utility class: a class writing a single declaration, taking its value from a closed scale.

// Closed scale: the number of selectable values for each property is fixed.
const SCALE = {
  "padding":       5,   // --spacing-0 .. --spacing-4
  "margin-block":  5,
  "gap":           5,
  "font-size":     6,
  "color":         8,
  "background":    8,
  "border-radius": 3,
  "display":       4,
};
const PREFIXES = ["", "hover:", "focus:", "wide:"];   // wide: wide-screen breakpoint

const utilityCount = Object.values(SCALE).reduce((t, n) => t + n, 0) * PREFIXES.length;
const UTILITY_BYTES = 30;                          // example: ".p-3{padding:var(--spacing-3)}"
const utilityTotal = utilityCount * UTILITY_BYTES;

console.log("--- utility class set (independent of component count) ---");
console.log(`  scale: ${Object.keys(SCALE).length} properties, ${Object.values(SCALE).reduce((t, n) => t + n, 0)} values total`);
console.log(`  prefix count: ${PREFIXES.length} (${PREFIXES.map((p) => p || "(no prefix)").join(", ")})`);
console.log(`  generated class count: ${utilityCount}`);
console.log(`  approximate size: ${utilityTotal} bytes`);

// Component notation: every new component adds new rules and new bytes.
const COMPONENT_RULES = 5;                           // average rules per component
const RULE_BYTES = 62;                               // average selector + declaration body

console.log("\n--- size of the two notations by component count ---");
console.log("components".padStart(10) + "component notation".padStart(20) + "utility notation".padStart(19) + "  smaller");
for (const n of [5, 10, 20, 40, 60, 80, 120]) {
  const componentTotal = n * COMPONENT_RULES * RULE_BYTES;
  console.log(
    String(n).padStart(10) +
    `${componentTotal} bytes`.padStart(20) +
    `${utilityTotal} bytes`.padStart(19) +
    "  " + (componentTotal < utilityTotal ? "component notation" : "utility notation"),
  );
}
const threshold = Math.ceil(utilityTotal / (COMPONENT_RULES * RULE_BYTES));
console.log(`  threshold: past ${threshold} components, the utility set stays smaller`);

// --- how many times the same declaration repeats across components ---
const COMPONENTS = {
  "measurement-card": ["padding: var(--spacing-1)", "border-radius: 4px", "display: grid", "gap: var(--spacing-0)"],
  "sidebar-box":        ["padding: var(--spacing-1)", "border-radius: 4px", "display: grid"],
  "masthead":         ["padding: var(--spacing-1)", "display: flex", "gap: var(--spacing-0)"],
  "measurement-filter": ["display: flex", "gap: var(--spacing-0)", "padding: var(--spacing-0)"],
};
const counter = new Map();
for (const declarations of Object.values(COMPONENTS))
  for (const d of declarations) counter.set(d, (counter.get(d) ?? 0) + 1);

console.log("\n--- declarations repeating across components ---");
const totalDeclarations = [...counter.values()].reduce((t, n) => t + n, 0);
for (const [d, n] of [...counter.entries()].sort((x, y) => y[1] - x[1]))
  console.log(`  ${d.padEnd(30)} in ${n} components`);
console.log(`  total declarations: ${totalDeclarations}, distinct: ${counter.size}, repeated: ${totalDeclarations - counter.size}`);

// --- length of the class list in the document ---
console.log("\n--- two notations of the same box ---");
const cardUtility = "grid gap-0 p-1 br-1 bg-surface";
const cardComponent = "measurement-card";
console.log(`  utility notation  : class="${cardUtility}"   (${cardUtility.length} characters, ${cardUtility.split(" ").length} classes)`);
console.log(`  component notation: class="${cardComponent}"   (${cardComponent.length} characters, 1 class)`);
console.log(`  if the card appears 12 times in the document, difference: ${(cardUtility.length - cardComponent.length) * 12} characters`);
--- utility class set (independent of component count) ---
  scale: 8 properties, 44 values total
  prefix count: 4 ((no prefix), hover:, focus:, wide:)
  generated class count: 176
  approximate size: 5280 bytes

--- size of the two notations by component count ---
components  component notation   utility notation  smaller
         5          1550 bytes         5280 bytes  component notation
        10          3100 bytes         5280 bytes  component notation
        20          6200 bytes         5280 bytes  utility notation
        40         12400 bytes         5280 bytes  utility notation
        60         18600 bytes         5280 bytes  utility notation
        80         24800 bytes         5280 bytes  utility notation
       120         37200 bytes         5280 bytes  utility notation
  threshold: past 18 components, the utility set stays smaller

--- declarations repeating across components ---
  padding: var(--spacing-1)      in 3 components
  gap: var(--spacing-0)          in 3 components
  border-radius: 4px             in 2 components
  display: grid                  in 2 components
  display: flex                  in 2 components
  padding: var(--spacing-0)      in 1 components
  total declarations: 13, distinct: 6, repeated: 7

--- two notations of the same box ---
  utility notation  : class="grid gap-0 p-1 br-1 bg-surface"   (30 characters, 5 classes)
  component notation: class="measurement-card"   (16 characters, 1 class)
  if the card appears 12 times in the document, difference: 168 characters

The first two blocks compare two growth curves. The utility set is constant: eight properties, forty-four values, and four prefixes produce 176 classes, and this number is unaffected by how many components get written. Component notation, on the other hand, grows linearly.

The model’s threshold comes out at 18 components. This number is not an exact value; it depends on scale width, prefix count, and rules per component. What it gives is not the threshold itself but the shape of the two curves: one constant, one growing.

The third block counts repetition. Four components carry thirteen declarations, but only six of them are distinct; seven declarations repeat. Utility classes turn exactly these six declarations into single rules.

Repetition Does Not Disappear, It Moves

The fourth block shows the approach’s cost. The same box is 16 characters in component notation, 30 characters in utility notation; when the card appears 12 times in the document, the difference comes to 168 characters.

Repetition removed from the stylesheet has moved into the document. When totaling bytes, this side has to be counted too; the two files ship together.

But the two repetitions are not of the same kind. Repetition in a stylesheet is managed by hand: changing one value requires finding four rules. Repetition in the document is usually inside a template or component abstraction; the card is written once and generated twelve times. The utility class approach therefore assumes a template-producing layer; in plainly written documents, the repetition really is managed by hand.

State and Breakpoint Prefixes

Since a utility class writes a single declaration, there is no direct place for pseudo-classes and media queries. The fix is to write the condition into the name:

.p-1        { padding: var(--spacing-1); }
.hover\:p-2:hover { padding: var(--spacing-2); }

@media (min-width: 48rem) {
  .wide\:p-2 { padding: var(--spacing-2); }
}

The colon inside the name is escaped in the selector with a backslash; the document writes hover:p-2, and the selector matches it as .hover\:p-2.

The cost of this is that the set grows by a multiple. In the output’s first block, four prefixes bring forty-four values up to 176 classes. Every new state and every new breakpoint grows the set by another factor; pruning the generated set down to only the classes that appear in the document is, for this reason, an inseparable part of the approach — and the warning from the third lesson applies here too: names must appear as full literal text in the source.

Where It Breaks

In three places. First, when a value outside the scale is needed: a specific aspect ratio for an image, or a measure coming from content, is not in the closed scale, and a one-off rule needs to be written.

Second is readability. A long class list says how the box looks, not what it is. A document writing class="measurement-card" explains itself; a list of ten classes does not.

Third is cases needing a complex selector. Style based on a sibling, style based on a descendant, or style depending on an ancestor’s state does not fit into single-declaration classes.

Mixed Use

The two approaches do not exclude each other, and in practice the boundary is drawn by this criterion: if the structure does not repeat, a utility class; if it repeats, a component class.

Generating a name to adjust the padding of a section that appears once in the page is unnecessary; copying a twelve-times-repeated card’s five declarations to twelve places is unnecessary too. The scale is read from the same custom properties in both notations, so the two layers share the same design constraint.

In the Station Page

/* utilities.css — single-declaration classes generated from a closed scale */
.p-0 { padding: var(--spacing-0); }
.p-1 { padding: var(--spacing-1); }
.gap-0 { gap: var(--spacing-0); }
.gap-1 { gap: var(--spacing-1); }
.grid { display: grid; }
.flex { display: flex; }
.bg-surface { background-color: var(--surface); }
.text-muted { color: var(--text-muted); }

This set is used for one-off adjustments on the measurement station page: the masthead’s bottom margin, the location section’s padding, the source note’s color. The measurement card, on the other hand, stays a component class — it appears twelve times in the page and has its own variants.

The distinction can be tied to a rule: if a class name appears once in the document, it can be written with utility classes; if it appears more than twice, it has earned being a component.

Summary

  • A utility class writes a single declaration and takes its value from a closed scale; the approach’s real justification is not brevity but the constraint that makes an out-of-scale value impossible.
  • The utility set’s size is independent of component count; component notation grows linearly, and the two curves cross at a certain component count.
  • Repetition does not disappear, it moves from the stylesheet into the document; the approach assumes a template-producing layer.
  • State and breakpoint prefixes grow the set by a multiple; pruning unused classes is for this reason part of the approach.
  • If the structure does not repeat, a utility class; if it repeats, a component class; both layers share the same custom-property scale.

Next Step

Five separate notation approaches were taken up in this topic, and all of them answered the same question from a different place: how style is organized. There is one more question, and it is asked by the browser — how much does the stylesheet itself delay the page opening, and how much work is done while matching selectors? The next lesson takes up style’s performance cost, and closes the course.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close