Find viewport widths your CSS breakpoints miss or double-cover, fractional pixels included.
Breakpoints are written by hand as pairs like max-width: 767px and min-width: 768px.
At 125% or 150% browser zoom the layout viewport is measured in fractions of a CSS pixel,
so it can be 767.2px wide, and neither rule matches: the page falls through to whatever the
base styles say. Mixing em and px, legacy max-width with range syntax
(width < 768px), and orientation or height queries makes these gaps harder to see by eye.
CSS linters check that a query is well formed. None of them tell you which viewports fall
through every rule in a breakpoint family, or which viewports get two rules that disagree.
mqgap is a small pipeline of exact computations. Nothing in it uses floating point.
1. Parse. Each @media prelude is parsed into an AST following the Media Queries
Level 4 grammar: media types, only and not, and/or/not conditions (mixing and and
or without parentheses is rejected, as the spec requires), comma lists, legacy
min-/max- features, and range syntax including the chained form
400px <= width < 800px. The features modelled are width, height, aspect-ratio,
orientation, resolution and -webkit-*-device-pixel-ratio. Error handling follows the
spec. A query that breaks the grammar becomes not all without affecting its siblings in the
list. An unknown feature or an invalid value, such as (min-width: 600), evaluates to
unknown, and not unknown is still unknown, so it still does not match.
2. Normalise to boxes. A query becomes a union of boxes. A box is a product of four
intervals over width × height × aspect-ratio × resolution. Every endpoint is an exact
rational (a bigint numerator and denominator) that records whether it is open or closed, so
max-width: 767px is (0, 767] and width < 768px is (0, 768). em and rem convert
at a configurable base (16px by default). in, cm, mm, Q, pt and pc convert
exactly, and so do dpi and dpcm. orientation: portrait is the half-plane
height >= width, which becomes the interval aspect-ratio <= 1 on the third axis.
The three-valued logic is carried as a pair of regions per condition: where it is true and
where it is false. and, or and not then map to intersection, union and swapping the pair.
The interval arithmetic has intersection, complement, subtraction (a box minus a box splits
into at most eight disjoint boxes, peeling one axis at a time), and a simplifier that merges
boxes differing on one axis. The aspect-ratio axis is not independent of width and height, so
emptiness is decided exactly by projection. The set of widths of a box is W ∩ H·R, and the
product and quotient of positive intervals are intervals whose ends are attained exactly when
both factors' ends are.
3. Families. A small zero-dependency tokenizer walks the stylesheet: comments, strings,
nested @media (conditions intersect), @supports and @layer (kept as part of the family
key), and skipped blocks such as @keyframes. Declarations under @media are grouped by
(selector, property). A selector list .a, .b contributes to both families.
4. Holes. For a family with covered region C, a hole is a viewport outside C that
has covered viewports on both sides along width, along height or along resolution. With
max-width: 767px and min-width: 768px the sliver in between is a hole. With only
min-width: 768px, the narrow viewports are a deliberate fallback to base styles and are not
reported. "Has a covered point to its left" is itself a box: a box (W, H, R, S) has a
point at the same height and resolution with smaller width exactly when w > inf W,
w/h > inf R, h ∈ H ∩ W/R and s ∈ S. Holes are therefore
complement(C) ∩ ((left(C) ∩ right(C)) ∪ (below(C) ∩ above(C)) ∪ (lowerRes(C) ∩ higherRes(C))),
computed exactly with no sampling.
5. Conflicts. Two rules in a family whose regions overlap, that set different values
(after whitespace normalisation) with the same !important-ness. They share a selector, so
they share specificity, and source order silently decides.
6. Reachability. A browser at device pixel ratio d lays out a viewport that is a
whole number of device pixels, so its CSS width and height are multiples of 1/d. For each
--dpr value mqgap looks for such a lattice point inside the hole (and checks d against
the resolution interval). When the aspect-ratio cut does not bind, the two axes are
independent and this is two integer-in-interval checks. Otherwise it scans candidate heights
outward from 900px.
@media (max-width: 767px) { .nav { display: none; } }
@media (min-width: 768px) { .nav { display: flex; } }- Rule 1 is the box
width ∈ (0, 767]. Rule 2 iswidth ∈ [768, ∞). - Covered:
(0, 767] ∪ [768, ∞). Uncovered:(767, 768), open at both ends because both 767 and 768 are matched. - Left of rule 1 is
width > 0and right of rule 2 iswidth < ∞, so every uncovered point is bordered on both sides: the hole is767px < width < 768px. - Witness: the midpoint, 767.5px wide, at a default height of 900px.
- dpr 1: the multiples of 1 in
(767, 768)are none, so not reachable. dpr 1.25: multiples of 0.8 include 767.2 (959 device pixels), so reachable. dpr 1.5: multiples of 2/3 include 2302/3 ≈ 767.333, so reachable. dpr 2: 767.5, so reachable.
At a 1.25 ratio (Windows display scaling at 125%, or 125% browser zoom on a 1x screen), a 959-device-pixel window gets neither rule.
Requires Node.js 20 or newer. No runtime dependencies.
From a clone of this repository:
npm install # also compiles TypeScript to dist/
node dist/src/cli.js examples/zoom.css
npm test # rebuilds, then runs the suitenpm link puts mqgap on your PATH if you want to run it by name.
mqgap <file.css>... [--dpr 1,1.25,1.5,2] [--em 16] [--media screen] [--json]
Real output for examples/zoom.css:
$ node dist/src/cli.js examples/zoom.css
examples/zoom.css: 5 families checked, 3 holes, 1 conflict
.nav { display }
line 5: @media (max-width: 767px) -> none
line 10: @media (min-width: 768px) -> flex
hole: 767px < width < 768px
witness 767.5x900
reachable at dpr 1.25 (767.2x900), dpr 1.5 (2302/3 (~767.333)x900), dpr 2 (767.5x900)
not reachable at dpr 1
falls back to "flex" from line 2
.menu-button { display }
line 6: @media (max-width: 767px) -> block
line 11: @media (min-width: 768px) -> none
hole: 767px < width < 768px
witness 767.5x900
reachable at dpr 1.25 (767.2x900), dpr 1.5 (2302/3 (~767.333)x900), dpr 2 (767.5x900)
not reachable at dpr 1
falls back to the property's inherited or initial value
.grid { grid-template-columns }
line 16: @media screen and (max-width: 47.9375em) -> 1fr
line 20: @media screen and (min-width: 48em) -> repeat(12, 1fr)
hole: 767px < width < 768px
witness 767.5x900
reachable at dpr 1.25 (767.2x900), dpr 1.5 (2302/3 (~767.333)x900), dpr 2 (767.5x900)
not reachable at dpr 1
falls back to the property's inherited or initial value
.card { padding }
line 34: @media (min-width: 600px) -> 16px
line 38: @media (max-width: 900px) -> 8px
conflict: 600px <= width <= 900px
line 34 sets "16px", line 38 sets "8px"; line 38 wins, e.g. at 900x900
Reading it:
hole:is the exact region, in range syntax.<means the endpoint is excluded.witnessis one concrete viewport inside it (width x height in CSS px).reachable at dpr …gives, for each device pixel ratio, a viewport on that ratio's pixel grid that hits the hole.not reachablemeans no such viewport exists; this is exact, not a sample.falls back toshows the unconditional declaration that applies inside the hole, if any. For.navthe fallback happens to be harmless; for.menu-buttonit is not.- The
.sidebarfamily (width < 1024px/width >= 1024px) is checked and not listed, because range syntax with a strict and a non-strict bound leaves no gap. - Media features outside the model (
hover,prefers-color-scheme, …) are listed asnote:lines, and those declarations are left out of their family.
Exit status is 1 when any hole is reachable at one of the --dpr values, 0 otherwise,
and 2 for usage or input errors. Conflicts are reported but do not change the exit status.
With --dpr 1 the same file exits 0: all three holes are sub-pixel.
--json emits the same report with exact endpoints as strings ("lo": "767", "loClosed": false)
so a CI step can consume it.
import { parseMediaQuery, toRegions, findHoles, findConflicts, formatBox, Rational } from 'mqgap';
const rules = ['(max-width: 767px)', '(min-width: 768px)'].map((q) => toRegions(parseMediaQuery(q)));
const [hole] = findHoles(rules, [Rational.parse('1.25')]);
formatBox(hole.box); // '767px < width < 768px'
hole.reachable[0].witness?.width.toString(); // '767.2'toRegions(list, { emPx, mediaType }) returns an array of boxes. evaluate(list, viewport)
is an independent direct evaluator. analyzeStylesheet(css, options) runs the whole pipeline.
There is no benchmark; mqgap makes no performance claim. Correctness is what the test suite
(npm test, 37 tests, under a second) checks:
- Parser conformance: 60 queries covering range syntax, chained ranges,
notprecedence,only, units, orientation, resolution, unknown features and the MQ4 error-handling examples, each compared with its expected normalised boxes. Another 16 grammar-breaking queries must parse as invalid and match nothing. - Semantic property test: 500 randomly generated query lists (including invalid ones,
unknown features and nested
not), each checked at 40 viewports with fractional widths, exactwidth = heightdiagonals and exact aspect-ratio boundaries. A direct evaluator walks the AST using the spec's matching rules and shares no code with the region arithmetic. The two must agree at all 20,000 points. - Hole soundness: 150 random families. Every reported witness, and every per-dpr lattice witness, is re-checked with the direct evaluator and must match no rule. Lattice witnesses must be whole device pixels.
- Hole completeness: 24 random breakpoint-style families, sampled on a 1/8px grid over 112 x 112 points. Every unmatched grid point with matched grid points on both sides of its row or column must lie in a reported hole, and no matched point may. Deleting the height-direction term from the hole finder makes this test fail with a counterexample.
- Region algebra: union, intersection, difference and complement of random boxes (including aspect-ratio cuts) are checked pointwise on a half-pixel grid.
- Named edge cases: 767/768 (reachable at 1.25, 1.5 and 2, not at 1);
47.9375em/48emat 16px and at 20px;width < 768px/width >= 768px(no hole); a 767.5/768 hole that is unreachable at 1, 1.25, 1.5 and 2 but reachable at 4; overlapping identical min-widths (no conflict);not screen and (min-width: 600px); portrait vs landscape tiling the plane, and portrait vsmin-aspect-ratio: 1/1conflicting exactly on the diagonal.
The spec for this tool suggested cutting aspect-ratio and orientation half-planes into
staircases of axis-aligned boxes over a bounded domain. I did not do that. A staircase is
wrong near the diagonal, and near a boundary is exactly where this tool has to be right: a
query like (orientation: portrait) against (min-aspect-ratio: 1/1) overlaps only on the
line width = height, and no finite staircase represents a line. Instead each box carries a
third interval on width/height. Intersection stays a per-axis operation, complement still
splits into a bounded number of boxes, and the property test can demand exact agreement at
points on the diagonal. The cost is that the axes are no longer independent. Emptiness,
witnesses and "has a neighbour to the left" all need the projection formulas above, not a
per-axis check, and lattice search with a binding aspect-ratio cut is a scan, not a formula.
The second decision was what counts as a hole. "Unmatched" alone is useless: every family
built from min-width rules leaves small screens unmatched on purpose. "Unmatched and
bounded" is not well defined once height and aspect ratio enter. "Unmatched, with matched
viewports on both sides along some axis" is easy to explain, and it can be computed exactly
as a region, not sampled. It also has a checkable grid analogue, which is what the
completeness test uses. It will flag a deliberate 300px gap between max-width: 300px and
min-width: 600px. I accept that, because the output shows the exact interval and the
fallback value, and a reviewer can tell a 300px gap from a 1px one at a glance.
- Only
width,height,aspect-ratio,orientationandresolution(plus-webkit-*-device-pixel-ratio) are modelled. Declarations whose queries use other features (hover,prefers-*,color,device-width, …) are skipped and listed, not analysed. - Families are keyed by exact selector text.
.a .band.a .bare merged, but.navandnav.navare not recognised as overlapping, and no cascade from other selectors is considered. Conflicts are reported between rules with the same selector only. @container,@scopeconditions,@supportsconditions and CSS nesting are not evaluated. Nested style rules (&:hover { … }) are skipped.@supportsand@layeronly separate families.- Values are compared as whitespace-normalised text, so
0and0pxcount as different values and produce a conflict. resolution: infiniteand degenerate ratios such as16/0are treated as unknown.- The viewport domain is strictly positive, so boolean-context queries like
(width)always match. - Reachability assumes both viewport dimensions are whole device pixels at the given ratio. When an aspect-ratio cut couples width and height, the lattice search examines at most 20,000 candidate heights. A hole that is a very thin wedge spanning more than that could be reported unreachable when a lattice point exists far from 900px.
- Holes are reported as boxes after merging. A region that is not a single box, for example an L-shape, is reported as several holes.
- No source maps, no
@importfollowing, and no SCSS/Less: run it on compiled CSS.
MIT. See LICENSE.