SCSS Writing Conventions
The core ideas behind all of these rules: accessibility comes first,
every design value flows from one source of truth, and every rule stays
easy to override. A raw hex color or a raw @media (min-width: 768px) in a
component file silently detaches from the design system and won't update when
the design changes. Deep selector nesting creates specificity that someone
later has to fight with !important. And no visual polish justifies a control
that keyboard users can't see focus on or that fails contrast.
North star: stay close to native CSS
Follow the same direction Dart Sass itself is heading: as CSS gains native powers, the Sass layer shrinks. When CSS can express something natively, write it the native way — the stylesheet stays valid longer, tooling and devtools understand it, and there's less compile-time magic to learn:
- Native functions over Sass functions at runtime:
calc(),min(),max(),clamp()instead of Sass math;color-mix()and relative color syntax (oklch(from ...)) instead ofdarken()/lighten()/color.adjustfor derived colors. - Custom properties over
$varswherever runtime works (see below). - Modern native selectors —
:is(),:where()(zero specificity),:has()— when they express the intent more directly than nesting gymnastics. - Native features as written:
oklch()colors, logical properties,aspect-ratio,gap— not Sass-era workarounds for them.
Sass earns its place only where CSS still can't go: the module system
(@use/@forward), build-time tokens and maps, values inside @media
queries, mixins for repeated patterns, and the syntax preferences codified in
this file. Everything else should read like tomorrow's plain CSS.
First: learn the project's system, then use it everywhere
Before writing any SCSS, look at how the project defines design values:
- A token/utility layer exists (a
$colors/$spacingmap, functions likecolor("primary")/spacing("m")/rem-calc(), abreakpoint()mixin, or a utility library such as@ryze-digital/scss-utilities): route every color, size, spacing, font-size, z-index, and media query through it. Never bypass it with a literal value. - No token layer exists: create one (
_tokens.scssor_variables.scss) for the values you need, with semantic names ("primary","m","medium") — then consume it. Don't inline values as a shortcut.
If a needed value is genuinely new (a new color, a new breakpoint), add it to the token source with a semantic name; don't hardcode it at the point of use.
Colors are always OKLCH
Define color values in oklch() — never hex, rgb(), or hsl(). OKLCH is
perceptually uniform (same lightness number = same perceived lightness across
hues), which makes derived shades honest (oklch(from var(--primary) calc(l - 0.1) c h)),
supports wide-gamut displays, and makes contrast reasoning tractable because
the L channel actually corresponds to what the eye sees.
// In the token source
$colors: (
"primary": oklch(48% 0.09 155),
"accent": oklch(74% 0.12 90),
"dark": oklch(22% 0.01 280),
);
When a design or prompt hands you a hex value, convert it to oklch at the
token layer — the hex never enters the codebase. Alpha variants use the slash
syntax: oklch(48% 0.09 155 / 0.15).
| Instead of | Write |
|---|---|
color: #6b4e9c; |
color: color("primary"); (or the project's equivalent) |
margin: 24px; |
margin: spacing("l"); |
width: 320px; |
width: rem(320px); — rem-based sizes, px only for borders/hairlines |
@media (min-width: 768px) |
@include breakpoint("medium") { ... } |
z-index: 999; |
z-index: z("modal"); (named ladder) |
Module architecture
- Always
@use/@forward, never@import(deprecated, pollutes the global namespace). - One partial per component (
_card.scss, or acard/folder with_index.scssforwarding_styles.scss/_variables.scss/_mixins.scssin larger projects). Register new partials in the entry file (main.scss) and keep that list alphabetical. - Import shared modules namespaced so origins stay visible:
@use "../tokens";
@use "../icons/variables" as icons;
- Never
@useanother component's style rules (that duplicates its CSS output) — only sharevariablesandmixinspartials. - Component-scoped constants (an animation duration, a max-width) are named
at the top — as custom properties on the component root by default, or as
SCSS
$variablesin the component's_variables.scsswhen they're needed at build time — never magic numbers in the middle of a rule.
CSS variables by default, SCSS variables only for build time
Prefer CSS custom properties (--name) over SCSS variables ($name) whenever
the value could matter at runtime — theming, variants, states, breakpoint
overrides, anything JS or a cascade layer might ever need to touch. A $var
is gone after compilation; a --var stays inspectable in devtools,
overridable per-scope, and themeable without a rebuild.
Reach for an SCSS $variable only when the value must exist at build time:
- values used inside
@mediaqueries (custom properties don't work there — breakpoints stay SCSS) - keys/values in Sass maps and arguments to Sass functions (
rem($size),math.div, color functions running at compile time) - mixin/function parameters and
@iflogic - values that feed generated code (loops emitting utility classes)
Custom properties for variants and state
When a value changes across variants (&.secondary), states (:hover,
:active), or breakpoints, declare it as a CSS custom property on the base
rule and override only the custom property in the variant. The property is
declared once; variants shrink to a few --overrides:
.button {
--background-color: #{color("primary")};
--color: #{white};
background-color: var(--background-color);
color: var(--color);
&.ghost {
--background-color: #{transparent};
--color: #{color("dark")};
}
}
.facts-list {
--columns: 1;
grid: {
template: {
columns: repeat(var(--columns), 1fr);
}
}
@include breakpoint("medium") {
--columns: 2;
}
}
SCSS expressions assigned to custom properties must be interpolated:
--color: #{color("primary")}; — without #{} Sass emits the expression text
literally.
Syntax preferences
Modern logical properties
Prefer CSS logical properties over physical ones — they follow writing mode and direction for free (RTL, vertical scripts) and read as intent rather than geometry:
| Physical | Logical |
|---|---|
margin-top / margin-bottom |
margin-block-start / margin-block-end |
margin-left + margin-right |
margin-inline |
padding-left / padding-right |
padding-inline-start / padding-inline-end |
top / left / right / bottom |
inset-block-start / inset-inline-start / ... |
width / height (text containers) |
inline-size / block-size |
text-align: left |
text-align: start |
Physical properties are still right when the direction is genuinely physical (a shadow falling downward, a transform) — but spacing and positioning should default to logical.
Nested property syntax
Write hyphenated properties with Sass property nesting whenever the prefix
is shared by a family of CSS properties. text-align, text-transform,
text-decoration all start with text-, so text is a real grouping — nest
it, even when only a single property from the family appears:
// Yes — the prefix has a property family (text-*, margin-*, grid-*, box-*, align-*)
text: {
align: center;
transform: uppercase;
}
text: {
align: center; // still nested when it's the only one
}
margin: {
block: {
start: spacing("l");
}
}
grid: {
template: {
columns: repeat(12, 1fr);
}
}
box: {
shadow: 0 8px 32px color("shadow");
}
align: {
items: center;
}
// No
text-align: center;
grid-template-columns: repeat(12, 1fr);
Don't split names whose prefix has no family. There is no other CSS
property starting with z-, so z-index stays z-index — z: { index } is
nonsense. Same for aspect-ratio (no other aspect-*), letter-spacing,
pointer-events, will-change, white-space: the hyphen there is part of
one indivisible name. The test: do other modern CSS properties share the
prefix? Yes → nest; no → keep the full name flat.
Single-word properties (display, gap, width, color, opacity) stay
flat.
Class naming
- Style through classes only — never IDs and never data-attribute selectors
(
[data-state="open"]). Data attributes carry JS state; if styling needs to react to state, have JS toggle a class (.active,.open) and style that. Element selectors only for true element defaults (button,ul). - Use a common, predictable vocabulary for structural elements so every
component reads the same:
wrapper,inner,list,item,title,description,content,media,actions. The component class provides the scope (.testimonials .item), so generic names don't collide. - Never build class names with
&-concatenation..icon { &-left {} }compiles to.icon-leftbut makes the class unfindable — grepping foricon-leftreturns nothing. Write the full class name out:
// Yes
.icon-left { ... }
.icon-right { ... }
// No
.icon {
&-left { ... }
&-right { ... }
}
& is still right for variants, states, and pseudo-elements (&.secondary,
&:hover, &::after) — those attach to the same class, they don't invent
a new name.
Nesting
- Selector nesting up to 5 levels is fine (matches the stylelint
max-nesting-depthlimit) — but don't mirror the full DOM tree when a shallower selector styles the same thing. Nest for scoping, not for documentation. - Nest variants and states with
&inside the component block rather than writing separate top-level rules.
Declaration order: structure first, then skin
Within a rule, write the block/layout declarations first — what the element IS and how it occupies space — then the visual/decorative ones. Someone scanning the file learns the layout before the paint:
- Layout & box:
display,position/offsets,flex/gridproperties,width/height,padding,margin,gap - Typography:
font,line-height,text-align - Color & decoration:
color,background,border,box-shadow,opacity - Behavior:
cursor,transition,animation
(CSS custom property declarations go at the very top of the rule, before everything else.)
Formatting
- 4-space indentation, double quotes for strings.
- One property per line; multi-value properties (transitions, gradients) may break across lines aligned under the property.
Responsive: mobile-first, named breakpoints
Write the small-screen styles as the base, then layer named breakpoints on
top with min-width semantics. Never write a raw width media query, and never
use max-width queries to "undo" desktop styles — that's a sign the base and
override are inverted.
.testimonials-grid {
display: grid;
gap: spacing("m");
@include breakpoint("medium") {
grid: {
template: {
columns: repeat(2, 1fr);
}
}
}
@include breakpoint("large") {
grid: {
template: {
columns: repeat(3, 1fr);
}
}
}
}
Accessibility is the first priority
When accessibility and aesthetics conflict, accessibility wins — a design tweak is negotiable, a user who can't perceive or operate the UI is not.
- Contrast: text must meet WCAG AA — 4.5:1 against its background (3:1 for large text and UI components/focus indicators). Check this whenever you pick a color pairing; OKLCH helps — a lightness (L) gap of roughly 0.4+ between text and background is a good starting heuristic, then verify. Never convey information by color alone (pair with an icon, weight, or text).
- Focus: pair every
:hoverwith:focus-visibleso keyboard users get the same affordance:&:hover, &:focus-visible { ... }. Never remove a focus outline without a visible, higher-contrast replacement. - Motion: wrap decorative animation in
@media (prefers-reduced-motion: reduce)guards when it's more than a subtle transition. - Preferences: respect
prefers-color-schemewhen the project themes, andprefers-contrast: morefor borderline pairings. - Targets & text: interactive targets ≥ 44×44px (or generous padding);
font sizes in
remso user zoom/browser settings scale them;line-height≥ 1.4 for body copy. - Hide decorative pseudo-content from screen readers where relevant and keep
visually-hiddenutilities for text that must exist but not display.
Reuse before reinventing
Before hand-rolling a hover effect, underline animation, visually-hidden
utility, or clearfix, check the project's existing mixins (_mixins.scss,
utils/, or the utility library). Extract a mixin when the same multi-property
pattern appears a third time — not preemptively.
Checklist before finishing any SCSS change
- No raw hex/rgb/hsl anywhere — colors are
oklch()and live only in the token source; no raw px sizes (except hairlines) or width media queries in component files — everything through tokens/helpers. - Text meets WCAG AA contrast (4.5:1, 3:1 for large text/UI); nothing is conveyed by color alone.
- Runtime-relevant values are CSS custom properties; SCSS
$varsonly where build time requires them (media queries, maps, compile-time functions). - New partials are registered in the entry file;
@use/@forwardonly. - Logical properties (
margin-block-start,padding-inline) over physical ones where direction isn't genuinely physical. - Hyphenated properties use nested syntax when the prefix has a CSS property
family (
text-*,box-*,align-*), even for a single property; names with no family (z-index,aspect-ratio,letter-spacing) stay flat. - Variant/state/breakpoint overrides go through CSS custom properties
(interpolated with
#{}). - Every
:hoverhas a:focus-visiblecompanion. - Mobile-first: base styles first, named breakpoints layered on.
- Selector nesting ≤5 levels; no IDs, no data-attribute selectors, no
&-concatenated class names. - Structural class names come from the common vocabulary (
wrapper,item,list,title,description, ...). - Within each rule: layout/box declarations first, then typography, then color/decoration, then transitions.
- Anything CSS can do natively (
calc,clamp,color-mix, relative colors,:is/:where/:has) is written natively — Sass only where build time demands it.