# Scss Conventions

> Conventions for writing clean, maintainable, accessibility-first SCSS that stays close to native CSS — design tokens instead of raw values, OKLCH colors, CSS custom properties and native functions by default (Sass only for build time), @use/@forward module architecture, mobile-first breakpoints, predictable class naming, structure-first declaration order. Use this skill whenever writing, editing, refactoring, or reviewing any .scss/.sass file — styling a new component, tweaking spacing/colors/layout, converting CSS to SCSS, or answering questions about stylesheet structure — even if the user just says "style this" or "make it match the design" without mentioning SCSS.

- Skill: `naimrahman08/scss-conventions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add naimrahman08/scss-conventions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/naimrahman08/scss-conventions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: naimRahman08 (https://skillmd.com/u/naimrahman08)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/naimrahman08/scss-conventions

---


# 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 of `darken()`/`lighten()`/`color.adjust`
  for derived colors.
- **Custom properties over `$vars`** wherever 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`/`$spacing` map, functions like
  `color("primary")` / `spacing("m")` / `rem-calc()`, a `breakpoint()` 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.scss` or `_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.

```scss
// 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 a `card/` folder with
  `_index.scss` forwarding `_styles.scss` / `_variables.scss` / `_mixins.scss`
  in larger projects). Register new partials in the entry file (`main.scss`)
  and keep that list alphabetical.
- Import shared modules namespaced so origins stay visible:

```scss
@use "../tokens";
@use "../icons/variables" as icons;
```

- Never `@use` another component's style rules (that duplicates its CSS
  output) — only share `variables` and `mixins` partials.
- 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 `$variables` in the component's `_variables.scss` when 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 `@media` queries (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 `@if` logic
- 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`:

```scss
.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:

```scss
// 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-left` but makes the class unfindable — grepping for
  `icon-left` returns nothing. Write the full class name out:

```scss
// 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-depth` limit) — 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:

1. Layout & box: `display`, `position`/offsets, `flex`/`grid` properties,
   `width`/`height`, `padding`, `margin`, `gap`
2. Typography: `font`, `line-height`, `text-align`
3. Color & decoration: `color`, `background`, `border`, `box-shadow`,
   `opacity`
4. 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.

```scss
.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 `:hover` with `:focus-visible` so 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-scheme` when the project themes,
  and `prefers-contrast: more` for borderline pairings.
- **Targets & text**: interactive targets ≥ 44×44px (or generous padding);
  font sizes in `rem` so 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-hidden` utilities 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 `$vars` only where
  build time requires them (media queries, maps, compile-time functions).
- New partials are registered in the entry file; `@use`/`@forward` only.
- 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 `:hover` has a `:focus-visible` companion.
- 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.

