css-dedup
v1.3.0
Published
CSS declaration deduplicator for maintainability and performance optimization
Maintainers
Readme
CSS Dedup, the CSS Declaration Deduplicator
CSS Dedup is a CSS maintainability and performance optimization tool that finds—and, when requested and where safe, consolidates—duplicate CSS declarations. It implements the technique of using declarations just once (“UDJO”) as originally described in “DRY CSS” (cf. CSS Optimization Basics): the same normalized property–value pair shouldn’t appear in more than one rule within the same scope. Where it does, CSS Dedup reports it—and allows to optimize the respective style sheet.
Note: CSS Dedup is still brand-new 🆕 and needs to be battle-tested 🧪. Please report any issues.
Example Optimization
Given:
.a {
color: red;
font-weight: bold;
}
.b {
margin: 0;
}
.c {
color: red;
}$ npx css-dedup default.css
(root)
duplicate color: red
.a (line 2)
.c (line 11)
Summary: 1 finding
Run with `--fix` to save 10 bytes (11.8%).Running with --fix folds .a and .c into a single rule for the shared declaration, and leaves everything else untouched:
.a {
font-weight: bold;
}
.b {
margin: 0;
}
.a, .c {
color: red;
}$ npx css-dedup --fix default.css
1 consolidated, 0 skipped
85 → 75 bytes (-10 B, -11.8%)
Wrote default.cssSince duplicate declarations cost bytes wherever they live—in the style sheet itself, and (uncompressed) over the wire—the byte counts reflect two payoffs at once: less to maintain, and less to transfer.
The two aren’t always aligned, though: Folding a declaration into a shared selector list adds that list’s bytes back, so consolidating one that only has a couple of long, otherwise-unrelated selectors in common can end up costing more than it removes. CSS Dedup’s two modes call this out, so it’s a call you can make consciously—it may be worth it if you value using declarations just once (maintainability), not if you’re optimizing purely for transfer size. (--fix --savings-only automates that call: A file whose consolidation would grow it is left untouched.)
Usage
CLI
npx css-dedup [options] <file…>Pass one or more files—each is analyzed (and, with --fix, rewritten) independently; with more than one file, output is grouped under a header per file. A directory is searched recursively for .css files (skipping node_modules and dotfolders); the result is unrolled into that same per-file list, so mixing files and directories works too. Pass - instead of a file to read CSS from STDIN (can’t be combined with other file arguments); in --fix mode this prints the consolidated CSS to STDOUT, rather than writing a file, so it composes in a pipeline—status/summary output moves to STDERR in that case, keeping STDOUT pure CSS.
| Option | Description |
| --- | --- |
| --fix, -f | Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for -) |
| --aggressive, -a | Also allow merges that are probably—but not provably—safe (see aggressive mode); on its own this widens the report, with --fix it applies the merges |
| --savings-only, -s | Leave a file untouched when its consolidation would make it bigger, not smaller (checked per file); only valid together with --fix, since report mode never writes |
| --ignore-selector <pattern>, -i | Regular expression for selectors to exclude from analysis (repeatable) |
| --ignore-path <pattern>, -p | Regular expression tested against each file’s path, relative to the working directory; a match excludes the file (repeatable) |
| --no-ignore-selectors-defaults, -n | Disable the built-in selector-hack ignore list |
| --config <path>, -c <path> | Path to a config file (defaults to css-dedup.config.js in the working directory, if present) |
| --help, -h | Show usage information |
--ignore-selector and --ignore-path are singular because they’re repeatable flags—each occurrence (-i pattern1 -i pattern2) adds one pattern. The corresponding config-file options, ignoreSelectors and ignorePaths, take an array. --ignore-path excludes whole files by path rather than by selector content, matched against each file’s path relative to the working directory—useful for keeping a directory scan out of a build output folder (node_modules and dotfolders are always skipped; nothing else is, by default).
Without --fix, CSS Dedup only reports. Report mode still runs the same safety checks --fix would, though, so a finding that is considered unsafe to auto-merge (an intervening declaration on some other selector, say) is called out right there, alongside the byte estimate for whatever is safe—rather than the estimate silently going missing for that group. Exit code is 1 if it finds anything to report (or, with --fix, anything skipped as unsafe or withheld by --savings-only) in any of the given files.
A file that fails to parse—invalid CSS, or a non-standard dialect PostCSS doesn’t accept—doesn’t stop the run: Its error is reported and CSS Dedup moves on to the rest.
Note: --fix rewrites CSS text only—it doesn’t regenerate a source map. If a rewritten file references one (a /*# sourceMappingURL=… */ comment, typically left by a build tool), CSS Dedup notes that the map is now stale.
Config File
For settings that should apply on every run—typically a project’s own ignoreSelectors—drop a css-dedup.config.js in the working directory (or point --config at one elsewhere, under any name).
These are the supported options, shown with their defaults (each can be omitted):
// css-dedup.config.js
export default {
ignoreSelectors: [], // additional selector patterns to exclude, e.g., [/^\.legacy-/]
ignoreSelectorsDefaults: true, // set to `false` to disable the built-in hack list
ignorePaths: [], // file paths to exclude, matched relative to the working directory, e.g., [/dist\//]
aggressive: false, // set to `true` to also allow probably-safe merges
savingsOnly: false // set to `true` to skip files whose consolidation would grow them (`--fix` runs only)
};CLI flags layer on top of the config file rather than replacing it: --ignore-selector patterns are added to ignoreSelectors from the config, --ignore-path patterns are added to ignorePaths, and --no-ignore-selectors-defaults always wins over ignoreSelectorsDefaults: true in the config. savingsOnly is a consolidation policy, so on plain report runs it only adjusts the payoff line (a report never writes anyway); on --fix runs it decides whether the file is written.
Programmatic Use
Install CSS Dedup in your project, e.g., via npm i -D css-dedup, then import and use what you need:
import { analyze, dedup } from 'css-dedup';
const { findings } = analyze(css);
const { css: output, applied, skipped } = dedup(css);Both functions accept an options object:
{
from: 'path/to/file.css', // used for source-map-style line numbers only
ignoreSelectors: [/^\.legacy-/], // additional selector patterns to exclude
ignoreSelectorsDefaults: true, // set to `false` to disable the built-in hack list
aggressive: false, // set to `true` to also allow probably-safe merges
savingsOnly: false, // set to `true` to withhold a consolidation that would grow the style sheet (`dedup()` only)
}analyze() returns { findings }, an array of objects:
{
scope, // `root`, or the at-rule chain the rules live in, e.g. `@media (min-width: 768px)`
key, // normalized `prop: value` (plus ` !important` if set)
redundant, // “true” if the same declaration repeats within one rule, absent otherwise
repeated, // “true” if this flags a selector (list) written more than once in one scope;
// `key` is then the selector list, and occurrences carry no `prop`/`value`
occurrences, // [{ selector, selectors, prop, value, line }, …]
}dedup() returns { css, applied, skipped, bytes }: css is the rewritten style sheet; applied lists what it did—each entry has redundant: true if it just dropped a same-rule (or same-at-rule-block) duplicate, folded: true if it folded a rule repeating the same selector into a later one, absent if it folded selectors from separate rules into one; skipped lists duplicate groups (and blocked same-selector folds) it left untouched along with why; and bytes is { before, after, saved }—UTF-8 byte counts of the style sheet before and after, since that’s what changes over the wire, not the character count, covering everything --fix did as one net figure. saved is before - after, so it’s negative on the rare file where the added selector-list text outweighs the removed declarations—dropping a same-rule duplicate never costs bytes, only folding selectors from separate rules can. With savingsOnly: true, a consolidation whose net saved would be negative is withheld: css comes back untouched, applied is empty, bytes reports no change (that’s what actually happened), and the declined outcome arrives as withheld: { count, bytes }—the number of merges and the byte counts the consolidation would have had (withheld is absent whenever nothing was withheld). dedupRoot() (the same function, operating on an already-parsed PostCSS root instead of a CSS string) returns the same shape minus css.
PostCSS Plugin Use
For dropping CSS Dedup into an existing PostCSS pipeline (alongside Autoprefixer, cssnano, etc.) instead of running it as a separate file-based pass, import the plugin from css-dedup/plugin:
import postcss from 'postcss';
import cssdedup from 'css-dedup/plugin';
// Report mode: Duplicate/redundant declarations surface as PostCSS warnings
const result = await postcss([cssdedup()]).process(css, { from: 'default.css' });
console.log(result.warnings());
// Fix mode: Rewrites the root in place; skipped merges still surface as warnings
const fixed = await postcss([cssdedup({ fix: true })]).process(css, { from: 'default.css' });
console.log(fixed.css);The plugin takes the same options as analyze()/dedup(), plus fix: true to switch it into consolidation mode (aggressive: true and savingsOnly: true work here, too—a withheld consolidation leaves the root untouched and surfaces as a warning). Since CSS Dedup is a source-hygiene tool—more like stylelint --fix than a bundle optimizer—it belongs early in a pipeline, on hand-authored CSS, before Autoprefixer and before minification; running it after either may duplicate work those tools do.
How It Works
CSS Dedup:
…parses the CSS with PostCSS.
…scopes rules by their DRY boundary—the root style sheet, the contents of an
@media/@supports/@layercondition, or one specific nested rule (native CSS nesting).- Declarations are only ever compared within the same scope: A rule’s own declarations are never compared against those of rules nested inside it, and rules in different
@layers (or different@media/@supportsconditions) can’t share a merged rule. - For reporting, two blocks with the same condition are the same scope even when written separately in the source (e.g., two
@media (min-width: 768px) {}blocks in different parts of the file)—matching is whitespace-insensitive but case-sensitive, since@layernames and selectors can be case-significant. --fixis more conservative here: It only ever folds rules that already live in the same physical block, since merging across two separate blocks would relocate a declaration past whatever sits between those blocks in the source—including rules in an entirely different scope, which the merge-safety check (step 6) has no visibility into. A duplicate split across two same-condition blocks is therefore reported, not auto-merged (unless--aggressiveis enabled—see aggressive mode).- Statement-form at-rules with no block (
@layer reset, base;) are skipped.
- Declarations are only ever compared within the same scope: A rule’s own declarations are never compared against those of rules nested inside it, and rules in different
…excludes selectors matching a hack pattern (vendor-prefixed pseudo-classes/elements, legacy IE selector hacks) from analysis by default—grouping those into a shared selector list risks the whole rule being dropped by browsers that don’t recognize the selector.
…normalizes each remaining declaration for comparison.
- Skips the contents of quoted strings,
url(), and custom property names throughout—those are case-sensitive, sovar(--Foo)/var(--foo)and--Foo/--fooare never treated as equal. Everything around such a protected segment still normalizes, though:VAR( --brand )matchesvar(--brand), andvar(--m, 0px)matchesvar(--m,0). - Compares custom property values verbatim (
--brand: #FFFand--brand: #fffdon’t match)—they’re substituted as-is wherevervar()references them—possibly somewhere case-sensitive—and scripts can read them back viagetPropertyValue(), so no two spellings are provably interchangeable (even--x: 0pxand--x: 0differ—only one is a validz-index: var(--x)). - Collapses whitespace, including just inside parentheses and around commas (
rgb( 255, 0, 0 )matchesrgb(255,0,0)), and folds value case—except for properties whose value is (or can contain) an author-defined custom ident (animation-name,counter-reset,container-name, and similar), since those are case-sensitive per CSS, unlike the predefined keywords everywhere else;animation-name: Fooandanimation-name: foocan name two different@keyframesblocks, so folding them would risk a false duplicate. - Collapses zero-value length units (
0px/0svh/0cqw→0)—angle/time/frequency/resolution units like0deg/0sare left alone, since unitless zero isn’t valid there. Zero percentages (0%) are collapsed to0, too, except for a short list of properties where a percentage can resolve against an indefinite reference size. - Collapses redundant decimal zeros (
.5/0.5/0.50→.5,1.0→1), drops a redundant leading+sign (+2px→2px), and ignores whitespace around/separators (12px/1.5matches12px / 1.5). - Canonicalizes equivalent color spellings:
white,#fff,#ffffff,#ffffffff,rgb(255, 255, 255), andrgb(255 255 255)all compare equal, as dotransparentandrgba(0, 0, 0, 0). Only lossless textual equivalences count—hsl()and percentage channels involve rounding, so they’re left alone (except in aggressive mode, which accepts the rounding). - Treats
font-weight: bold/700andnormal/400as equivalent (the longhand only—picking the weight out of thefontshorthand would require parsing the value). - Collapses repeated shorthand values, following the omission rules in reverse:
margin: 0 0matchesmargin: 0,padding: 1px 2px 1px 2pxmatches1px 2px,border-radius: 1px/1pxmatches1px, and two-value pairs likegap/overflow/place-itemscollapse the same way. - Treats the
border/outlinenoneand0values as equivalent. - Canonicalizes
<time>values to milliseconds:0.3smatches300ms. Always exact—convertingstomsis a decimal-point shift, never rounding—so this runs regardless of aggressive mode, the same as the zero-value and decimal collapsing above. - Sorts
min()/max()arguments, including nested calls:min(100%, 500px)matchesmin(500px, 100%), since mathematical min/max is commutative.clamp()’s three arguments are positional (minimum, preferred, maximum) and are left in place, as isminmax()(grid track sizing—a different function, despite the name). - In aggressive mode only: Canonicalizes
<angle>values to degrees—90degmatches0.25turnand100grad.grad/turnconvert to degrees exactly;radinvolves π, so that conversion is rounded, the same lossy-but-aggressive-only treatmenthsl()gets above.
- Skips the contents of quoted strings,
…reports any normalized declaration that occurs in more than one rule within a scope, and separately flags declarations repeated within a single rule—including within a selector-less at-rule block like
@font-faceor@page, which have declarations of their own but, unlike two rules, are never compared against each other (there’s no selector list to fold two@font-faceblocks into). It also reports a selector (list) written more than once within one scope—the same smell one level up from a repeated declaration—matched as a set, so.a, .band.b, .acount as the same selector list; only within one physical block, though, since two same-condition@mediablocks repeat their selectors by construction.…consolidates (with
--fix) only when it’s provably safe.- First, a declaration repeated within the same rule (or the same selector-less at-rule block) is collapsed to its last occurrence—unconditionally safe, since nothing moves across a rule boundary, so none of the checks below apply to it.
- Rules repeating the same selector (list) within one scope are folded into the last of them, earlier declarations first—which preserves every same-selector cascade outcome—but only if nothing in between touches any of the moved properties (the same intervening-rule check the declaration merges below use). Rules holding anything but declarations (nested rules, say) stay put.
- Identical rules—two or more rules whose declarations are exactly the same set of shared declarations—are folded into one rule with the combined selector list, rather than being split per declaration, provided their declaration order agrees wherever the properties overlap (and the usual intervening-rule check clears).
- Entangled duplicate groups (groups sharing rules) that fit no coordinated shape aren’t simply abandoned: Each group’s safe stretches of occurrences still consolidate individually, into a fresh rule placed at the stretch’s end. No shared rule’s selector is ever rewritten, so nothing leaks between the groups, and a member whose own later declarations overlap the shared property sits the merge out (relocating the declaration past its own tail would flip which one wins).
- Then, a duplicate group spread across separate rules is merged by folding its selectors into the last occurrence—one line per selector if that’s already how the file writes multi-selector rules, comma-separated on one line otherwise.
- Keeps whichever of the group’s equivalent raw spellings is shortest (e.g.
.5over0.50)—CSS Dedup only picks among spellings already present in the source, so it doesn’t synthesize a shorter one, which would be a minifier’s job. - Removes the declaration from the other occurrences—but only if no other rule between the first and last occurrence also sets that property or a shorthand/longhand overlapping it (
marginandmargin-left,border-colorandborder-top-color, etc.), for any selector. - One narrow exception to “any other rule”: If that rule’s selector is provably mutually exclusive with the group’s—right now, that only covers an exact-match attribute value on the same attribute, on what is provably the same element (
html[lang="da"] avs.html[lang="de"] a, since an attribute can only ever hold one value andhtmlis unique per document)—it can’t actually match the same element, so it’s not a threat to this particular merge and doesn’t block it. (Aggressive mode widens this exception to selectors that are merely likely disjoint.) “Provably the same element” means the differing attribute sits on the selector’s subject, is connected to it purely through>/+combinators, or sits onhtml/:root; across a descendant or~combinator,.x[data-v="1"] pand.x[data-v="2"] pcan match the very samep(nested.xwrappers), so those don’t count as exclusive. - If a merged rule (including the last occurrence itself) also carries a declaration for an overlapping property, that declaration is split out into its own small rule—keeping that occurrence’s own, original selector—placed right after the merged rule, rather than blocking the merge outright: Folding every selector onto one shared declaration block would otherwise hand that overlapping extra to selectors that never had it. Exception: If that extra is itself duplicated elsewhere in the same scope, it’s left alone and the whole merge is skipped instead, since splitting it here would orphan that other duplicate’s own merge.
- If something does block it, the merge is skipped and reported rather than risking a cascade change. A blocker fences, though—it doesn’t forbid: Occurrences on the same side of it still consolidate among themselves (their own spans are clean, so the same safety argument applies), and the group is reported as skipped either way, since the duplicate keeps existing across the blocker.
- Consolidation runs to a fixed point: One merge can unblock or create another (a fresh merged rule may repeat an existing rule’s selector list, an emptied rule stops fencing a span), so the passes repeat until nothing changes.
Overall, CSS Dedup is conservative by design and will leave some safe merges for manual review.
test/fixtures/*.css contains small example style sheets that exercise each of these behaviors, including nesting (nesting.css) and @layer (layers.css)—run node bin/css-dedup.js test/fixtures/<file>.css (add --fix for merge-safety.css, and --aggressive for aggressive.css) to see them in action.
Aggressive Mode
By default, CSS Dedup only consolidates what it can prove safe. --aggressive (-a) also allows merges that are probably safe:
Merging across separately-written same-condition blocks. Two
@media (min-width: 768px)blocks apply under the exact same runtime condition, so their duplicates consolidate—usually the biggest single lever on real files. The accepted risk: Rules from other scopes sitting between the two blocks stay invisible to the intervening-rule safety check, so a merge could move a declaration past one that matters. A conditional block this empties (@media,@supports,@container) is removed; an emptied@layershell is kept, since a layer’s first appearance sets the layer order.Assuming rules with disjoint-looking selectors don’t overlap. An intervening rule whose subject compound shares no class, ID, or type with the group’s (e.g.,
.btn:hoverbetween two.cardoccurrences) no longer blocks a merge. Almost always right with BEM-style class naming—but nothing stops one element from carrying both classes, which is why this isn’t provable. Anything the heuristic can’t read confidently (escapes,:is()/:not()and friends) still blocks.Rounding-based color equivalences.
hsl(0 0% 100%)≡#fff, and percentagergb()channels (rgb(100% 0% 0%)≡#f00)—excluded by default because the equivalence goes through browser rounding rather than being purely textual.Rounding-based angle equivalences.
<angle>values canonicalize to degrees:90deg≡0.25turn≡100grad.grad/turnconvert exactly, butradinvolves π, so any non-zeroradvalue is rounded to compare—the whole feature is gated behind the flag rather than splitting it by which unit pair happens to be exact.Property aliases.
word-wrap/overflow-wrapandgrid-gap/gap(plus the row/column variants) are pure synonyms in current browsers, so their duplicates merge—keeping the last occurrence’s spelling, which changes the legacy-support surface (a browser old enough to know onlyword-wraploses the declaration whenoverflow-wrapis the spelling kept).
What aggressive mode deliberately does not do: drop same-rule overrides with differing values (color: red; color: oklch(…)). That pattern is CSS’s fallback mechanism for progressive enhancement, and there is no way to tell an intentional fallback from an accident. (Overrides that are really the same color spelled two ways—color: #fff; color: hsl(0 0% 100%)—do collapse, via the color equivalence above.)
The byte economics don’t change with the flag—aggressive mode just unlocks more merges, each carrying the same trade-off between the declaration removed and the selector-list bytes added. Cross-block merges usually save, since they remove whole rules or blocks; declaration-only merges between rules with long selectors can grow the file, so --aggressive can also tip a style sheet further into growth. Either way, the usual growth notes call it out—including in the parenthetical previews shown when the flag is off.
--aggressive deliberately doesn’t imply --fix: The two flags are orthogonal—--aggressive sets how much risk to accept, --fix whether to write. On its own, --aggressive widens report mode (aggressive equivalences surface as findings, the savings estimate includes the aggressive merges), which is exactly the preview you want for the merges that carry risk; add --fix to apply them. Since these merges are not provable, review the diff and test the affected pages after an aggressive --fix—the CLI reminds you, counting how many of the merges actually rode on the flag. Conversely, without the flag, reports and --fix runs note in parentheses what --aggressive would add.
You might like some of my other work:
- Optimization tools: hihtml · HTML Minifier Next · ObsoHTML · CSS Dedup · Image Guard · Compressor.js Next · .htaccess Punk
- Defense tools: IA Defensa
- Resources for quality web development: Articles · Books (including On Web Development) · News · Terminology
