Pragmatic CSS is a naming and file-structure convention for scalable stylesheets. No dependencies, no build plugins. Works with plain CSS, SCSS, Less, or any preprocessor.
The core rule: a class name tells you exactly which file it lives in.
.form--nav--search → component/form/nav/_search.scss
.btn--primary → component/btn/_primary.scss
.sidebar__header → component/sidebar/_index.scss
PCSS draws from SMACSS (Base/State/Theme layers), BEM (element __ and modifier -- separators), and OOCSS (single-responsibility, open/closed principles).
| Class | Type | File |
|---|---|---|
.btn |
component | component/btn/_index.scss |
.btn__icon |
element | component/btn/_index.scss |
.btn--primary |
subclass | component/btn/_primary.scss |
.is-hidden |
state | base/_global-state.scss |
.has-error |
state | base/_global-state.scss |
.u-uppercase |
utility | base/_utilities.scss |
.theme-dark |
theme | base/_definitions.scss |
- Component / Element / Subclass
- State
- Utility
- Theme
- File Structure
- Naming Conventions
- Selector Conventions
- CSS Cascade Layers
- Tooling
A component is a self-contained, reusable UI block: btn, nav-bar, dialog, progressbar. Each component gets its own directory with a base file _index.scss.
| Class | File |
|---|---|
.panel |
component/panel/_index.scss |
.nav-bar |
component/nav-bar/_index.scss |
An element is a structural part of a component. It cannot be reused outside its parent.
| Class | File |
|---|---|
.panel__header |
component/panel/_index.scss |
A subclass extends a component via inheritance. Base styles go in _index.scss, variant styles go in a separate file named after the subclass.
| Class | File |
|---|---|
.panel--primary |
component/panel/_primary.scss |
<div class="dialog dialog--alert">...</div>
<div class="dialog dialog--prompt">...</div>Elements are styled within the scope of each subclass:
.shopping-cart {
// base styles shared by all variants
}
.shopping-cart--default {
.shopping-cart__heading { color: white; }
}
.shopping-cart--inverse {
.shopping-cart__heading { color: black; }
}<div class="progressbar progressbar--big">
<output class="progressbar__status">Install is 70% complete</output>
<progress class="progressbar__progress" value="70" max="100"></progress>
<div class="progressbar__actions">
<div class="icon icon--pause">pause</div>
<div class="icon icon--play is-hidden">resume</div>
</div>
</div>component/progressbar/_index.scss
.progressbar {
position: relative;
}
.progressbar__progress {
border: 0;
position: absolute;
width: 100%;
bottom: 0;
left: 0;
appearance: none;
&::-webkit-progress-bar { /* ... */ }
&::-webkit-progress-value { /* ... */ }
&::-moz-progress-bar { /* ... */ }
}
.progressbar__status {
display: flex;
position: relative;
font-size: 1rem;
}
.progressbar__actions {
position: absolute;
bottom: 0;
right: 0;
> .icon { /* ... */ }
}component/progressbar/_big.scss
.progressbar--big > .progressbar__status {
font-size: 1.6rem;
text-transform: uppercase;
padding: 16px;
}component/progressbar/_small.scss
.progressbar--small > .progressbar__status {
font-size: 1.1rem;
padding: 11px;
}States are toggled by JavaScript to reflect runtime behavior: .is-expanded, .is-hidden, .has-error. They are not static helpers.
<div class="main has-error">
<aside class="sidebar is-hidden">...</aside>
</div>component/_main.scss
.main {
/* default style */
&.has-error {
/* error state */
}
}base/_global-state.scss
/* JS-toggled only */
.is-hidden {
display: none !important;
}Utilities are static, single-purpose helpers set once in markup and never toggled by JS. They use a u- prefix to distinguish them from states.
| Class | File |
|---|---|
.u-hidden |
base/_utilities.scss |
.u-uppercase |
base/_utilities.scss |
.u-sticky |
base/_utilities.scss |
base/_utilities.scss
.u-hidden { display: none !important; }
.u-uppercase { text-transform: uppercase; }
.u-sticky { position: sticky; top: 0; }The distinction: if a class is set once in markup and stays, it is a utility (.u-). If code adds or removes it at runtime to reflect a behavior change, it is a state (.is- / .has-).
Themes swap visual style based on context. The recommended approach uses CSS custom properties so no recompile is needed.
<html class="theme-dark">
<div class="l-main">
<aside class="sidebar">...</aside>
</div>
</html>base/_definitions.scss
:root { --sidebar-bg: white; --sidebar-color: black; }
.theme-dark { --sidebar-bg: #1a1a2e; --sidebar-color: white; }
.theme-warm { --sidebar-bg: #f5f5f0; --sidebar-color: #333; }component/sidebar/_index.scss
.sidebar {
background-color: var(--sidebar-bg);
color: var(--sidebar-color);
}If you cannot use custom properties, drive themes through a SCSS mixin and theme map. Each theme generates a descendant selector per component.
@mixin theme-dialog($theme) {
$bg: get-theme-style($theme, "bg");
.theme-#{$theme} .dialog {
background-color: #{$bg};
}
}
@each $theme in $themes {
@include theme-dialog($theme);
}base/_definitions.scss
$themes: baz qux;
@function get-theme-style($theme, $key) {
$baz-map: ("bg": $baz-bg);
$qux-map: ("bg": $qux-bg);
@if $theme == "baz" { @return map-get($baz-map, $key); }
@if $theme == "qux" { @return map-get($qux-map, $key); }
@return map-get($baz-map, $key);
}styles/
├── component/
│ ├── btn/
│ │ ├── _index.scss ← .btn base styles
│ │ └── _primary.scss ← .btn--primary
│ └── form/
│ ├── _index.scss ← .form base styles
│ ├── auth/
│ │ ├── _index.scss
│ │ └── _login.scss
│ └── nav/
│ ├── _index.scss
│ └── _search.scss
└── base/
├── _normalize.scss
├── _base.scss
├── _definitions.scss ← variables, theme tokens
├── _global-state.scss ← .is-*, .has-*
├── _utilities.scss ← .u-*
├── _animations.scss
└── mixin/
└── _media.scss
The class name encodes the file location. .form--nav--search lives in component/form/nav/_search.scss.
| Class | Entity |
|---|---|
.btn, .main-nav |
component (hyphen-delimited words only) |
.main-nav__title |
element (part of a component) |
.btn--primary, .main-nav--landing |
subclass (variant) |
.is-hidden, .has-success |
state (JS-toggled) |
.u-uppercase, .u-sticky |
utility (static helper) |
.theme-default, .theme-dark |
theme |
Further reading:
Browsers read selectors right-to-left. Long selectors slow rendering and bloat output. Keep nesting to 3-4 levels max.
@extend generates a comma-separated selector list whose length depends on how many other rules share the same placeholder. This can be surprising in compiled output and hard to debug.
Reserve @extend %placeholder for cases where you genuinely want selectors to share a rule-set. Never @extend a concrete class like .container--compact, because that couples the subclass to the base class's source position.
// avoid
.container--loose {
@extend .container--compact;
}
// prefer
@mixin container-compact-styles {
.col { padding: $col-pad; }
}
.container--compact { @include container-compact-styles; }
.container--loose { @include container-compact-styles; /* plus overrides */ }IDs belong in HTML fragments, not CSS. When the same ID serves as both a CSS hook and a JS target, changes to one break the other.
<!-- avoid -->
<button id="submit-btn" class="btn btn--primary">Submit</button>
<!-- prefer -->
<button class="btn btn--primary" data-action="submit">Submit</button>
<!-- or -->
<button class="btn btn--primary js-submit">Submit</button>input[disabled] and similar semantic attributes are fine for styling. Avoid styling via arbitrary data-* attributes; use classes instead.
button.btn ties the style to a specific element type. Drop the tag so the class can move freely across different elements.
.feed nav ul li h2 means the style only works at that exact DOM path. Single-class selectors let you move components anywhere in the markup without rewriting CSS.
CSS @layer (baseline since 2022) formalizes what PCSS manages through naming discipline. PCSS layers map directly:
@layer base, component, utility, state, theme;Declaring layers upfront fixes their priority order regardless of source order. A later layer always wins at equal specificity. You no longer need !important on state rules or extra nesting for theme overrides.
@layer base { /* _normalize.scss, _base.scss */ }
@layer component { /* component/**/_index.scss, subclasses */ }
@layer utility { /* _utilities.scss */ }
@layer state { /* _global-state.scss */ }
@layer theme { /* token overrides */ }The file-path-to-class-name convention is only as strong as your enforcement. Add stylelint with stylelint-selector-bem-pattern to validate selectors automatically:
npm install --save-dev stylelint stylelint-selector-bem-pattern.stylelintrc
{
"plugins": ["stylelint-selector-bem-pattern"],
"rules": {
"plugin/selector-bem-pattern": {
"componentName": "[a-z][a-z0-9-]*",
"componentSelectors": {
"initial": "^\\.{componentName}(?:__[a-z][a-z0-9-]*)?(?:--[a-z][a-z0-9-]*)?$"
},
"utilitySelectors": "^\\.u-[a-z][a-z0-9-]*$"
}
}
}

