SCSS Formatter Guide: Prettier, Stylelint, Nesting, and Pre-commit
SCSS sits at the intersection of CSS and a programming language — it has variables, loops, mixins, and modules. That power makes consistent formatting more important, not less. Poorly nested SCSS is harder to debug than unformatted plain CSS. Use the SCSS Formatter to clean up stylesheets directly in your browser, then set up automation with the toolchain described here.
Related reading: Code Formatters Guide for the broader picture and Python Formatter Guide for a comparable deep-dive in another language.
SCSS vs Sass: Which Syntax Is Current
The Sass preprocessor ships two syntaxes. SCSS (Sassy CSS) uses curly braces and semicolons — it is a superset of CSS, so any valid CSS file is also valid SCSS. The indented syntax (the original .sass extension) uses significant whitespace instead of braces, similar to Python or Pug.
| Feature | SCSS (.scss) | Indented (.sass) |
|---|---|---|
| Braces and semicolons | Required | Omitted |
| Valid CSS as-is | Yes | No |
| Prettier support | Full | Partial |
| Ecosystem tooling | Dominant | Niche |
| Recommended for new projects | Yes | Only if team preference |
The Sass team considers SCSS the primary syntax. Unless your team has a strong existing preference for indented style, choose SCSS. In mixed-format codebases — where legacy .sass files coexist with new .scss — configure Prettier separately per extension using overrides in .prettierrc.
Prettier for SCSS: Default Rules and Key Options
Prettier treats SCSS as a first-class language. Run prettier --write "**/*.scss" and it enforces consistent quotes, spacing around operators, trailing semicolons, and brace placement without configuration. The defaults that matter most for stylesheets:
- singleQuote — defaults to
false(double quotes). Set totrueto use single quotes in string values. - printWidth — defaults to
80. Long selector chains and@includecalls can exceed this; Prettier wraps them at property boundaries, not mid-selector. - tabWidth / useTabs — standard indentation settings, applied consistently inside nested blocks.
// .prettierrc
{
"singleQuote": true,
"printWidth": 100,
"overrides": [
{
"files": "*.scss",
"options": {
"singleQuote": false
}
},
{
"files": "*.sass",
"options": {
"parser": "scss"
}
}
]
}Prettier does not reorder declarations or enforce property grouping — that is stylelint's domain. Prettier only handles whitespace, quotes, and line wrapping.
Stylelint: Stylistic vs Logic Rules
Stylelint fills the gap Prettier leaves. It catches logic errors (unknown properties, duplicate selectors, invalid color values) and can enforce stylistic rules (property order, selector patterns, unit preferences). The critical distinction:
- Stylistic rules — spacing, quotes, color notation. These overlap with Prettier. If you run both tools, conflicts produce formatting fights on every save.
- Logic rules — invalid declarations, duplicate properties, specificity issues. Prettier never touches these. Always keep them in stylelint.
The solution is stylelint-config-prettier (now merged into stylelint itself as of v15). Stylelint v15+ disables all stylistic rules by default. If you use an older config that re-enables them, extend stylelint-config-prettier last in your config array to turn them off again:
// .stylelintrc.json
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-prettier"
],
"rules": {
"scss/no-global-function-names": true,
"scss/at-import-no-partial-leading-underscore": true,
"no-duplicate-selectors": true,
"no-descending-specificity": true
}
}Nesting: The 3-Level Max Rule
SCSS nesting generates compound selectors. Three levels of nesting produce a selector with three segments — .card .header .title — which is already specific enough to cause override headaches. Beyond three levels, generated CSS becomes brittle and hard to override without escalating specificity further.
The practical rule: nest at most 3 levels deep. Pseudo-classes and pseudo-elements (&:hover, &::before) do not count toward the limit since they don't add a selector segment.
// Bad — 4 levels deep
.nav {
.list {
.item {
.link { // 4th level: generates .nav .list .item .link
color: blue;
}
}
}
}
// Good — flat with BEM
.nav__link {
color: blue;
&:hover { // pseudo-class: not a nesting level
color: darkblue;
}
&--active { // BEM modifier: 1 level
font-weight: 600;
}
}Enforce the limit automatically with stylelint:
"max-nesting-depth": [3, { "ignore": ["blockless-at-rules", "pseudo-classes"] }]@use and @forward Replacing @import
Sass deprecated @import and it will be removed in a future release. The replacements are @use and @forward:
- @use — loads a module into a namespace. Variables, mixins, and functions are accessed as
namespace.variable, preventing global namespace pollution. - @forward — re-exports a module's members so callers of your file can access them without importing the source directly. Use in
_index.scssbarrel files.
// _tokens.scss
$color-primary: #0057ff;
$spacing-base: 8px;
// _index.scss (barrel)
@forward "tokens";
@forward "mixins";
// component.scss
@use "../design-system" as ds;
.button {
background: ds.$color-primary;
padding: ds.$spacing-base * 2;
}Prettier preserves @use and @forward declarations exactly, only normalizing whitespace. Stylelint's scss/at-use-no-unnamespaced rule flags bare @use without an as alias to keep namespace references explicit.
Sorting Declarations
Property order is a team convention, not a Prettier concern. The three common approaches:
| Strategy | Description | Tooling |
|---|---|---|
| Alphabetical | Properties sorted A-Z. Easiest to learn, no grouping decisions. | stylelint-order |
| Grouped | Positioning, box model, typography, visual, animation. | stylelint-config-recess-order |
| Concentric | Outside-in: position, size, border, background, text. | stylelint-config-concentric-order |
// .stylelintrc.json — grouped ordering via recess
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-recess-order"
]
}Pick one strategy and stick to it. Mixing strategies across files or components produces the worst outcome — no strategy at all.
BEM with SCSS
BEM (Block Element Modifier) pairs naturally with SCSS's & parent selector. Elements use &__element and modifiers use &--modifier:
// card.scss
.card {
border-radius: 8px;
padding: 16px;
&__header {
font-size: 1.25rem;
font-weight: 600;
}
&__body {
color: #444;
}
&--featured {
border: 2px solid #0057ff;
}
&--featured &__header {
color: #0057ff;
}
}The &--featured &__header pattern generates .card--featured .card__header, which is the correct BEM compound selector. Keep this to a single level; deeper modifier-element combinations signal that the block should be split.
Stylelint enforces BEM naming via selector-class-pattern:
"selector-class-pattern": ["^[a-z][a-z0-9-]*(__[a-z0-9-]+(--[a-z0-9-]+)?|--[a-z0-9-]+)?$", {
"message": "Class name must follow BEM convention"
}]CSS Custom Properties vs SCSS Variables
SCSS variables ($color-primary) are compile-time constructs — they vanish in the output CSS. CSS custom properties (--color-primary) are runtime values, readable and writable by JavaScript, and can change inside media queries or with :root overrides.
| Use case | Prefer |
|---|---|
| Design tokens shared with JS (theming, dark mode) | CSS custom properties |
| Internal SCSS math and mixins | SCSS variables |
| Values that vary per component instance | CSS custom properties |
| Build-time feature flags | SCSS variables |
A common hybrid: define design tokens as CSS custom properties on :root, then reference them in SCSS using var(). This gives you runtime flexibility without losing SCSS's compile-time calculations.
// _tokens.scss
:root {
--color-primary: #0057ff;
--spacing-base: 8px;
}
// component.scss
@use "../tokens";
.button {
// Runtime: responds to JS or :root overrides
background: var(--color-primary);
// Compile-time: used in SCSS math only
$internal-padding: 12px;
padding: $internal-padding ($internal-padding * 2);
}Configuration and Pre-commit Setup
A minimal but complete formatting stack for SCSS projects:
npm install --save-dev prettier stylelint stylelint-config-standard-scss \
stylelint-config-prettier stylelint-config-recess-order lint-staged husky// .prettierrc
{
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2
}// .stylelintrc.json
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-recess-order",
"stylelint-config-prettier"
],
"rules": {
"max-nesting-depth": [3, {
"ignore": ["blockless-at-rules", "pseudo-classes"]
}],
"scss/at-import-no-partial-leading-underscore": true,
"scss/no-global-function-names": true,
"no-duplicate-selectors": true,
"no-descending-specificity": true
}
}// package.json — lint-staged config
{
"lint-staged": {
"*.scss": [
"prettier --write",
"stylelint --fix"
]
}
}# Install husky pre-commit hook
npx husky init
echo "npx lint-staged" > .husky/pre-commitWith this setup, every git commit automatically formats and lints staged SCSS files. Prettier runs first to normalize whitespace, then stylelint runs with --fix to apply auto-fixable rule violations.
Common Pitfalls
- Interpolation in strings:
#{$variable}inside quoted strings is valid SCSS but confuses some formatters. Prettier handles it correctly; older stylelint versions sometimes flag interpolated property values. Pin to a recent version. - @import deprecation: Sass will remove
@importin a future major version. Run the official migration tool (sass-migrator module) to convert a codebase to@use/@forwardautomatically. - Map syntax changes: Sass maps now support trailing commas and unquoted keys in newer syntax. If your formatter strips trailing commas from maps, check that your Prettier version supports the Sass map syntax update (Prettier 3.x+).
- Global SCSS functions: Functions like
lighten()anddarken()are deprecated in favor ofcolor.adjust()from thesass:colormodule. Stylelint'sscss/no-global-function-namesrule flags these at lint time.
Paste any SCSS file into the SCSS Formatter to instantly apply Prettier formatting in your browser — no installation required. Once you are satisfied with the output, add the toolchain above to enforce the same style on every commit.