dead-styles
v0.1.7
Published
Find unused CSS-in-JS classes (tss-react / MUI makeStyles / JSS) and unused global Sass/SCSS/CSS classes across your whole project, not just one file
Maintainers
Readme
dead-styles
Find CSS-in-JS classes (tss-react / MUI makeStyles / JSS) that are defined but never used anywhere in the project — including when the styles hook is defined in one file and called from several others (a very common convention: Component.styles.ts + Component.tsx, or a shared hook consumed by many components across a monorepo).
Unofficial, third-party tool. Not affiliated with or endorsed by tss-react, MUI, or Knip.
Why not just use an ESLint rule?
There's already a good ESLint rule for this: eslint-plugin-tss-unused-classes, officially recommended by the tss-react docs. Use it if it fits your setup.
Its limitation (inherent to how ESLint rules work — one file's AST at a time) is that it can only see a class as "used" if classes.foo appears in the same file as the makeStyles/tss.create() call. The moment styles are defined in their own file and the hook is called from a different file (or from several different files/packages), every class in that styles file gets reported as unused — a 100% false-positive rate for that very common layout.
dead-styles uses the TypeScript compiler API (via ts-morph) to resolve the hook's declaration, find every call site across the whole project (any file, any package in a monorepo), and union up class usage across all of them before deciding anything is dead.
What it supports
makeStyles((theme) => ({...}))— classic MUI v4 /@mui/stylessingle-call formmakeStyles(options)((theme, params) => ({...}))— tss-react's curriedcreateMakeStyles()formmakeStyles()({...})exported as a bareexport default(thetss-react/muiconvention), with no local variable at all — each importing file picks its own local name, and every one of them is resolved back to the same definitiontss.create({...})/tss.create((params) => ({...}))tss.withParams<...>().create(...),tss.withName(...).create(...), and any other chain ending in.create(...)- Both
const classes = useStyles()(direct binding) andconst { classes } = useStyles()(destructured, with or without renaming) - A hook re-assigned to a plain local variable before it's called (
import styles from "./styles"; const useStyles = styles; ...; useStyles()), followed transitively through any number of hops - Cross-file and cross-package (monorepo
paths-alias) resolution of every call site
What it deliberately does NOT guess
To keep the false-positive rate near zero, dead-styles skips (rather than reports on) a hook whenever it can't fully verify usage:
- The styles object has a computed property key (
{ [dynamic]: {...} }) — the class's real name isn't known statically. - A class is accessed via a computed/dynamic key (
classes[someVariable]) instead ofclasses.foo— could be any class. - The whole
classesobject is forwarded somewhere we can't follow (spread with{...classes}, passed as a prop to a child component, assigned to another variable, returned from a function, etc.). - The hook itself (not its call result) is spread into a different
makeStyles()/tss.create()call's styles object elsewhere — a real production pattern:const styles = { ...stylesRow, ...stylesRowInner }; const useStyles = makeStyles()(styles);. That merged, re-wrapped hook can make any of the spread hook's classes reachable through a call site that never actually calls the original hook at all, so once this is seen, nothing about that original hook is reported as dead.
Skipped hooks are reported separately so you know what wasn't checked, but never show up as "dead."
Global stylesheet classes (--sass / --scss / --css)
Some projects also keep a global stylesheet of plain classes (text styles, color utilities, etc.), applied via literal strings (className="textStylesActive", clsx(...), classnames(...)) rather than through a CSS-in-JS hook. Pass one or more --sass <path> (indented syntax), --scss <path> (brace syntax), and/or --css <path> flags — any combination, repeatable — to also check those:
npx dead-styles scan --tsconfig ./tsconfig.json \
--sass ./src/styles/global.sass \
--scss ./src/styles/global.scss \
--css ./src/styles/global.cssThis works completely differently from the CSS-in-JS strategy above — there's no hook, no call site, no classes object to trace. Instead:
- Every top-level class selector in the file is a candidate —
.fooor.foo, .bar(including split across lines). "Top-level" means different things depending on the syntax:--sass(indented syntax): an unindented line. A pseudo-class like&:hover, a modifier block like.root-blazing .foo, or anything under a@mediablock is indented, so it's a nested reference, not a definition, and is ignored.--scss/--css(brace syntax): a selector block that isn't nested inside any{ }at all. SCSS nesting (&:hover { ... },.foo .bar { ... }), and anything inside an@media/@supports/@keyframes/@font-faceblock, is one level deeper and is ignored the same way.
- Every JS/TS/JSX/TSX file in the project is scanned for that exact class name appearing anywhere as a literal token — as a bare string, inside
clsx()/classnames()/cx()(string arguments or object keys), or inside a template literal. - Anything never found this way is reported as an unused global stylesheet class.
Known blind spots:
- A class name assembled dynamically at runtime (
'textStyles' + variant) won't be seen as a literal token and will be reported as unused even if it's actually applied. This is a one-directional risk — it can under-report (miss a real usage) but never over-report a class that's genuinely referenced by a static string, so it's a safe default, just not exhaustive. - Only simple class selectors are recognized as definitions; compound selectors (
.a.b), descendant/tag/id selectors, and anything nested (see above) aren't picked up as definitions — they're silently skipped, never guessed at. - For
--scss, a//line comment right before aurl(...)is deliberately not treated as the start of a comment when it's preceded by a:(coversurl(http://...)/url(https://...)), but an unquoted, protocol-relativeurl(//cdn.example.com/...)has no:to guard it and could still be mis-treated as a comment start. Quoting the URL (url("//cdn.example.com/...")) avoids this entirely and is good practice regardless.
Not (yet) supported
- Plain CSS Modules (
*.module.css) — seecheck-unused-cssfor that. styled-components/emotion'sstyledtagged templates.- React Native
StyleSheet.create/eslint-plugin-react-native'sno-unused-stylesterritory. - Tailwind utility classes — Tailwind's own JIT compiler already purges genuinely unused utility classes at build time, so static "unused class" detection isn't meaningful there. Custom
@layer componentsclasses are a different, still-unhandled case.
These may become separate strategies/tools later; PRs welcome.
Install
npm install --save-dev dead-stylesUsage
npx dead-styles scan --tsconfig ./tsconfig.jsonOptions
| Flag | Description | Default |
| --- | --- | --- |
| --tsconfig <path> | Path to tsconfig.json (required) | — |
| --sass <path> | Path to a global Sass (indented syntax) file to also scan for unused classes (repeatable) | — |
| --scss <path> | Path to a global SCSS (brace syntax) file to also scan for unused classes (repeatable) | — |
| --css <path> | Path to a global plain CSS file to also scan for unused classes (repeatable) | — |
| --format <format> | text, markdown, or json | text |
| --out <path> | Write output to a file instead of stdout | — |
| --fail-on <mode> | dead-styles (exit 1 if any found) or never | dead-styles |
Example
npx dead-styles scan --tsconfig ./tsconfig.json --format markdown# dead-styles report
Analyzed 6 style hooks (2 fully verified, 3 skipped).
Found dead classes in 3 style hooks:
### `useCardStyles` — src/Card.styles.ts:3
Called from 2 sites.
- `unusedLabel` (line 7)
...How it decides a class is "used"
For each style hook (useStyles, or whatever you named it):
- Collect the top-level keys of the object passed to
makeStyles(...)/tss.create(...)— these are the candidate class names. - Find every place the hook is called anywhere in the project via TypeScript's own symbol resolution (works across
pathsaliases in a monorepo). - At each call site, figure out what the returned
classesgot bound to (const classes = ...orconst { classes } = ...). - Trace every reference to that local
classesbinding within its own file (a local variable can't be referenced outside its lexical scope — cross-file usage happens through additional call sites, not through this identifier). - A class is "used" if it shows up as
classes.fooorclasses['foo']at any call site, anywhere in the project. - Anything left over is reported as dead — unless step 3 or 4 hit something unverifiable (see above), in which case the whole hook is skipped instead.
License
MIT
