Sass Mixins vs Extends: Comparing the Compiled CSS Output and Source Order

Compare Sass mixins and extends by examining the actual compiled CSS output, source order risks, and why gzip makes code duplication less of a concern.

Open a compiled stylesheet sometime and watch what Sass did to your rules. A declaration you wrote once appears in thirty places. Another rule you wrote last is now first. The cascade you built is not the cascade the browser gets. The difference is mixins versus extends. A mixin copies its declarations into every rule that includes it. An extend groups every extending rule into one comma-separated block, and that grouping is what can break your cascade when source order changes.

The choice between @mixin and @extend is not about taste. It is about what each one does to the rule list in your final CSS, and what that does to specificity and the cascade. A mixin keeps each rule isolated: the specificity is exactly what the selector itself would be, no more, no less, and the source order of your file stays predictable. An extend pulls rules from wherever they appear in your Sass and merges them into one grouped block at the position where the extend is declared. That move silently reorders your cascade. It changes which declaration wins a conflict, even though you never touched the rule that lost.

Know the failure mode of each before you write another line. The @extend directive causes the most confusion. It looks like a convenience and behaves like a landmine. Consider a button and a call-to-action button, both sharing a background and padding. Written with @extend, your input Sass looks like this. The compiled output is where the source-order specificity risk lives.

How Extend Reorders Your Cascade

// input.scss
.button {
  padding: 8px 16px;
  background: #333;
  color: #fff;
}
.cta-button {
  @extend .button;
  background: #06c;
}
/* compiled.css */
.button, .cta-button {
  padding: 8px 16px;
  background: #333;
  color: #fff;
}
.cta-button {
  background: #06c;
}

Now write another rule that targets .button for a different background, and place that rule before the extended block in your Sass. The extend merged .cta-button into the grouped rule, so the later declaration for .button no longer overrides the shared background for the call-to-action version. The call-to-action rule is now part of a block that sits earlier in the compiled output. The cascade has been reordered behind your back. That is the source-order risk: not a theoretical edge case, but a daily hazard when you extend a rule that any other rule targets.

The @mixin directive avoids that entire class of problem by duplicating the declarations. The same component written with a mixin produces output where each rule is self-contained. Source order is preserved exactly as written.

How Mixins Preserve Source Order

// input.scss
@mixin button-base {
  padding: 8px 16px;
  background: #333;
  color: #fff;
}
.button {
  @include button-base;
}
.cta-button {
  @include button-base;
  background: #06c;
}
/* compiled.css */
.button {
  padding: 8px 16px;
  background: #333;
  color: #fff;
}
.cta-button {
  padding: 8px 16px;
  background: #333;
  color: #fff;
  background: #06c;
}

The duplication is the point. Each rule is self-contained. A later rule that targets .button will override it exactly as you expect, because the two rules are no longer joined. The price you pay is output bloat: the same three declarations appear twice. In a large design system that repetition adds kilobytes before compression. But here is what you need to know: gzip and brotli both exploit repetition. Repeated identical declaration blocks compress extremely well, often to a fraction of their uncompressed size. The wire cost of mixin duplication is far smaller than the raw byte count suggests.

Placeholder Selectors: The Safer Extend

Now the third sample shows the placeholder selector, %placeholder, which is the safer way to use @extend when you must. A placeholder rule is not emitted on its own; it only exists to be extended. This avoids the pitfall of extending a real class that might be used elsewhere. It does not remove the source-order risk entirely, because the grouped rule is still placed at the point where the extend appears.

// input.scss
%card-base {
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 12px;
}
.article-card {
  @extend %card-base;
}
.promo-card {
  @extend %card-base;
  background: #f0f0f0;
}
/* compiled.css */
.article-card, .promo-card {
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 12px;
}
.promo-card {
  background: #f0f0f0;
}

Notice the placeholder itself does not appear in the compiled CSS. That is the advantage of %placeholder over extending a real class: you never risk a rule that targets the placeholder for its own sake, because no such rule exists. But the grouped output is still vulnerable to source order. If a later rule in your Sass targets .article-card with a different border, that rule will lose the cascade conflict with the grouped block if the grouped block appears later in the compiled file. The grouped block’s specificity is the same as the individual rule’s, and later source order wins at equal specificity.

When To Choose Which

So when do you choose one over the other? Use @mixin when the shared declarations are few, when the component is small, or when you know the rules will be targeted independently later. Use @extend with a %placeholder only when you are certain the grouped rule list will never conflict with a later rule, and when the savings from grouping matter more than the risk of cascade reordering. Extends are safe for base styles that are never overridden per component, like a reset or a utility class that is always the same. Mixins are the default for anything with variants. Each variant can then be targeted without inheriting the grouping hazard.

Specificity: The Other Axis

The specificity of the compiled rule is another axis that separates the two. When you @include a mixin, the specificity of the rule is exactly the specificity of the selector you wrote it on. A class gives (0,1,0), an ID gives (1,0,0), and so on. The mixin’s contents inherit that specificity, no more. When you @extend a rule, the specificity of the extended block is the specificity of the extending rule, not the extended one. That distinction matters when you extend a low-specificity rule from a high-specificity one: the grouped block suddenly carries the higher specificity for all members of the group. This causes surprising overrides elsewhere in your stylesheet.

Here is a concrete example. You have a base class .error with a red color, and you extend it from an ID selector #login-error. The compiled rule will be #login-error, .error { color: red; }. Now any rule that targets .error with a different color, even one with a higher specificity like .form .error, will lose to the ID in the grouped block, because (1,0,0) beats (0,2,0) regardless of source order. The cascade is not wrong. It is just very easy to misread when the grouping is invisible in your source.

Sass @mixin directive usage

The @mixin directive is straightforward: declare the mixin with @mixin name { ... }, then include it with @include name;. You can pass arguments, set default values, and use variable arguments for flexibility. The compiled output is a literal copy of the declarations at each inclusion point. Mixins are the right tool when your shared declarations are small enough that duplication is cheaper than the mental overhead of tracking an extend’s side effects. A common pattern is a mixin for a clearfix, a visually-hidden utility, or a small set of typographic styles that appear in a handful of components.

The failure case for mixins is overuse without arguments. Define a mixin that takes no parameters and produces the same declaration block in fifteen places, and you have created output bloat that a simple selector group would have avoided. Gzip will compress the repetition, but the uncompressed CSS is still larger. Every browser that parses it does the work of reading the same declarations fifteen times. The maintainability cost is real too: change the mixin, and you regenerate fifteen rules. Forget to update one include because you hand-wrote a rule that bypassed the mixin, and you now have divergent styles that are hard to find.

Sass @extend directive problems

The @extend directive problems are more subtle and more dangerous. The first is the media query pitfall: you cannot extend a rule that is defined outside the current media query scope. Sass throws an error if you try to @extend .button from within a @media block when .button is defined at the top level. That error signals that the compiler cannot place the grouped block into a media context without breaking the cascade. The fix: use a mixin inside the media query instead. It duplicates the declarations locally and keeps the media scope intact.

The second problem is extending unrelated rules. Extend a class that has no semantic relationship to the extending rule, and you create a comma-separated list in the compiled CSS that is unwieldy and hard to read. Extending .button from a .card-footer and a .nav-link produces a block that lists all three, even though they share only a couple of declarations. That grouping makes the stylesheet harder to debug. A change to .button now affects all three rules, and the source of that linkage is invisible in the compiled file.

CSS output bloat comparison

The CSS output bloat comparison between the two approaches is a trade-off between duplication and grouping. Mixins duplicate. Extends group. The raw size of mixin output is larger, but compression narrows the gap. The raw size of extend output is smaller, but the grouping can create specificity and source-order hazards that are worse than a few extra bytes. Measure the uncompressed and the gzipped size of your compiled CSS for both approaches on a real component, not a toy example. You will often find the gzipped difference is under a kilobyte, while the debugging cost of an extend gone wrong is measured in hours.

Source order specificity risk

The source order specificity risk is the reason extends are not a default. Write a rule that targets a selector that is also part of an extended group, and you have no guarantee that your new rule will win the cascade. The grouped block may sit later in the compiled output than your new rule, and at equal specificity, later wins. The only way to be safe is to know exactly where every extend lands in the compiled file. That means reading the output CSS, not just the input Sass. It is a burden most teams do not want, and it is why mixins are the safer default for anything likely to be overridden.

Selector grouping

Selector grouping is the mechanism behind @extend. The compiler collects every rule that extends a given target and emits them as a single comma-separated block. That is efficient for the browser: it parses one block instead of several. It is efficient for file size: shared declarations appear once. But the efficiency comes at the cost of coupling. Every rule in the group is now tied to the same declaration block. Any future rule that targets one member of the group must account for the group’s specificity and position. The %placeholder mitigates the coupling by removing the extended target from the output, but it does not remove the coupling among the extending rules themselves.

Code duplication

Code duplication is the price of mixins, and it is worth naming what it protects. When you @include a mixin, you are deliberately choosing to repeat declarations so that each rule stands alone. That isolation means a later rule targeting one selector will not be affected by a change to another rule that happens to share the same mixin. The uncompressed file is larger. Any mistake in the mixin propagates to every include. But the mistakes are easier to find because they are visible in each rule.

Gzip compression

Gzip compression changes the size calculus. Repeated declaration blocks, which is what mixins produce, compress extremely well. The compressor finds the repeated sequences and stores them once. Brotli, the default on most modern servers, does the same with even better ratios. The actual bytes sent over the wire for a mixin-heavy stylesheet are often only slightly larger than an extend-heavy one. Sometimes the difference is negligible. Base your decision on maintainability and cascade safety, not on the uncompressed byte count. Check browser support for Brotli on caniuse if your deployment targets older clients.

Selector specificity

Selector specificity is the unitless triple that determines which declaration wins at equal origin and layer. A class is (0,1,0), an ID is (1,0,0), and a type selector is (0,0,1). Mixins do not change the specificity of the rule they are included in; the rule’s specificity is exactly the selector you wrote. Extends do change it. The grouped block takes the specificity of the most specific selector in the group. A mixin cannot accidentally raise the specificity of a rule. An extend can, and that raised specificity can then override rules that were written later and with more intent.

Media query

The media query interaction is where extends break down. Sass resolves extends at compile time and cannot place a grouped block into a different media context. Any attempt to @extend an outer rule from inside a @media block throws an error. Mixins do not have that restriction. They copy declarations into the media block, which is valid CSS. If you need shared styles inside a media query, use a mixin. If you must use an extend, define the placeholder inside the media query as well. That placeholder will only exist in that media scope.

Directive

The @mixin and @extend directives are both part of the Sass preprocessor, compiled to plain CSS at build time. Neither is a browser API. Neither has any baseline status in the browser. The compiled output is what the browser sees, and that output is where all the differences live. Knowing the directive syntax is the first step. Knowing what each directive does to the rule list, the specificity, and the source order is the step that prevents production bugs.

Preprocessor

Sass is a preprocessor. The compilation step is the place where correctness is decided. A preprocessor can reorder, group, or duplicate rules. It does so according to its own rules, which are documented but often ignored. The practical consequence: you cannot predict the cascade from your source Sass alone. You must either read the compiled CSS or choose constructs that have predictable output. Mixins have predictable output: they copy. Extends have less predictable output: they group, and grouping depends on where the extend appears relative to the extended rule and every other rule in the file.

Output bloat

Output bloat is the named risk of mixins. It is real but manageable. Manage it by using mixins for small, self-contained declarations. Avoid nesting mixin includes inside other mixin includes without limit. Deep nesting of mixins can cause exponential duplication. Each inner include copies its declarations into each outer include. If the outer mixin is included multiple times, the inner declarations appear multiple times as well. That is the worst case for output bloat. Avoid it by keeping mixin composition shallow.

Maintainability

Maintainability is the final arbiter. A stylesheet that is easy to change without surprises is better than one that is a few bytes smaller but requires reading the compiled output to understand the cascade. Mixins, with their explicit duplication, are easier to reason about because each rule is self-contained. Extends, with their implicit grouping, are harder because the linkage is invisible in the source. For a team that ships daily, the cost of an unexpected cascade override is far higher than the cost of a few kilobytes.

Selector grouping in practice

When you do use @extend with a %placeholder, limit the number of extending rules to a handful. A group of two or three is manageable. A group of twenty is a maintenance nightmare. A change to the placeholder affects all twenty. A change to one extending rule can accidentally override the others through specificity. Keep the group small. Keep the shared declarations minimal. Never extend a rule that is likely to be targeted by another rule later.

FAQ

Q: Can I use @extend inside a media query? A: You can extend a rule that is defined inside the same media query, but extending an outer rule from inside a media query throws an error. Use a mixin instead.

Q: Does @mixin increase my CSS file size a lot? A: Yes, in raw bytes, but gzip and brotli compression reduce the difference substantially. Measure both on your actual component before deciding.

Q: What is a placeholder selector? A: A %placeholder is a selector that only exists to be extended and does not appear in the compiled CSS unless it is extended. It avoids the risk of a real class being picked up unintentionally.

Q: Which is safer for a large design system? A: Mixins are safer for anything likely to be overridden, because they keep each rule isolated. Extends are safer only for base styles that are never targeted by other rules.

Q: Does @extend change specificity? A: Yes. The grouped block takes the specificity of the most specific selector in the group, which can raise the specificity of the other selectors in the group.

The one sentence no competitor will write

The sentence that no other page on this subject is likely to write is this: “Extending a real class is a cascade bug waiting to happen, and the only safe use of @extend is with a %placeholder selector, and even then only when the group has three or fewer members and none of them will ever be the target of a later rule.”