npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

css-dedup

v1.3.0

Published

CSS declaration deduplicator for maintainability and performance optimization

Readme

CSS Dedup, the CSS Declaration Deduplicator

npm version Build status Socket GitHub Sponsors

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.css

Since 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:

  1. parses the CSS with PostCSS.

  2. scopes rules by their DRY boundary—the root style sheet, the contents of an @media/@supports/@layer condition, 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/@supports conditions) 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 @layer names and selectors can be case-significant.
    • --fix is 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 --aggressive is enabled—see aggressive mode).
    • Statement-form at-rules with no block (@layer reset, base;) are skipped.
  3. 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.

  4. normalizes each remaining declaration for comparison.

    • Skips the contents of quoted strings, url(), and custom property names throughout—those are case-sensitive, so var(--Foo)/var(--foo) and --Foo/--foo are never treated as equal. Everything around such a protected segment still normalizes, though: VAR( --brand ) matches var(--brand), and var(--m, 0px) matches var(--m,0).
    • Compares custom property values verbatim (--brand: #FFF and --brand: #fff don’t match)—they’re substituted as-is wherever var() references them—possibly somewhere case-sensitive—and scripts can read them back via getPropertyValue(), so no two spellings are provably interchangeable (even --x: 0px and --x: 0 differ—only one is a valid z-index: var(--x)).
    • Collapses whitespace, including just inside parentheses and around commas (rgb( 255, 0, 0 ) matches rgb(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: Foo and animation-name: foo can name two different @keyframes blocks, so folding them would risk a false duplicate.
    • Collapses zero-value length units (0px/0svh/0cqw0)—angle/time/frequency/resolution units like 0deg/0s are left alone, since unitless zero isn’t valid there. Zero percentages (0%) are collapsed to 0, 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.01), drops a redundant leading + sign (+2px2px), and ignores whitespace around / separators (12px/1.5 matches 12px / 1.5).
    • Canonicalizes equivalent color spellings: white, #fff, #ffffff, #ffffffff, rgb(255, 255, 255), and rgb(255 255 255) all compare equal, as do transparent and rgba(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/700 and normal/400 as equivalent (the longhand only—picking the weight out of the font shorthand would require parsing the value).
    • Collapses repeated shorthand values, following the omission rules in reverse: margin: 0 0 matches margin: 0, padding: 1px 2px 1px 2px matches 1px 2px, border-radius: 1px/1px matches 1px, and two-value pairs like gap/overflow/place-items collapse the same way.
    • Treats the border/outline none and 0 values as equivalent.
    • Canonicalizes <time> values to milliseconds: 0.3s matches 300ms. Always exact—converting s to ms is 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) matches min(500px, 100%), since mathematical min/max is commutative. clamp()’s three arguments are positional (minimum, preferred, maximum) and are left in place, as is minmax() (grid track sizing—a different function, despite the name).
    • In aggressive mode only: Canonicalizes <angle> values to degrees—90deg matches 0.25turn and 100grad. grad/turn convert to degrees exactly; rad involves π, so that conversion is rounded, the same lossy-but-aggressive-only treatment hsl() gets above.
  5. 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-face or @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-face blocks 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, .b and .b, .a count as the same selector list; only within one physical block, though, since two same-condition @media blocks repeat their selectors by construction.

  6. 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. .5 over 0.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 (margin and margin-left, border-color and border-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"] a vs. html[lang="de"] a, since an attribute can only ever hold one value and html is 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 on html/:root; across a descendant or ~ combinator, .x[data-v="1"] p and .x[data-v="2"] p can match the very same p (nested .x wrappers), 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 @layer shell 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:hover between two .card occurrences) 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 percentage rgb() 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: 90deg0.25turn100grad. grad/turn convert exactly, but rad involves π, so any non-zero rad value 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-wrap and grid-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 only word-wrap loses the declaration when overflow-wrap is 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: