postcss-viewport-fallback
v1.6.1
Published
PostCSS plugin that adds classic fallbacks for all dynamic/small/large viewport units (dvh, svh, lvh, dvw, dvmin, ...).
Maintainers
Readme
postcss-viewport-fallback
The universal fallback layer for modern CSS viewport units — all 18 of them:
dvw,dvh,dvi,dvb,dvmin,dvmax(dynamic)svw,svh,svi,svb,svmin,svmax(small)lvw,lvh,lvi,lvb,lvmin,lvmax(large)
Two strategies, one plugin:
duplicate(default, zero-runtime) — inserts a classicvh/vw/... twin before the original declaration; old browsers use the twin, modern browsers use the real unitcss-var— rewrites units tocalc(var(--pvf-dvh, 1vh) * N)with an auto-injected@supportsseed, optionally paired with a ~1 KB (gzip) runtime that gives legacy browsers actually dynamicdv*values (sv*/lv* are approximated)
Works in calc(), @media/@supports/@container params, nested CSS, Tailwind output, Vue/Svelte SFCs.
Features
Core Transformation
- All 18 viewport units — dynamic, small, and large ×
w/h/i/b/min/max - Two strategies: zero-runtime
duplicatetwins, or dynamiccss-var+ runtime - Works inside
calc(),min(),max(),clamp(), and nested functions of any depth - Decimals, negative and signed values, scientific notation, case-insensitive units
- Safe parsing with
postcss-value-parser— never touches strings,url(), or idents - Fully configurable:
preserve,onlyProperties,excludeProperties,customUnits,includeCustomProps - Performance:
browserslistauto-skip,fastSkipper-file scan, dedup, concurrent-safe stats
At-Rule Support
Fallback generation inside:
@media@supports@container(including nested container queries)
At-rule example
@media (min-height: 100dvh) { ... }
/* becomes */
@media (min-height: 100vh) { ... }
@media (min-height: 100dvh) { ... }Example
Input
.app {
height: 100dvh;
width: calc(100dvw - 20px);
}Output
.app {
height: 100vh;
height: 100dvh;
width: calc(100vw - 20px);
width: calc(100dvw - 20px);
}Do you still need this in 2026?
Dynamic viewport units are Baseline Widely Available (~93% global support: Chrome 108+, Safari 15.4+, Firefox 101+). For a typical US/EU consumer app the fallback is close to optional.
You do still need it if your browserslist includes any of:
- Emerging-market browsers — Opera Mini (no support, ever), UC Browser, QQ, KaiOS
- Smart-TV / digital-signage — Tizen & webOS pin Chromium per model year; 2016–2022 TVs never reach Chromium 108
- Embedded WebViews / kiosks / car head units stuck on pre-108 Chromium
- Long-tail / public-sector device matrices that keep old iOS & Android in scope
The honest pitch: if you don't need it, browserslist: true makes the plugin cost exactly zero. If you do, it's the most complete option available. See the comparison below.
Options
viewportFallback({
strategy: 'duplicate', // or 'css-var' (dynamic legacy values, pairs with the runtime)
preserve: true, // false removes original dvh, keeps only vh fallback
includeCustomProps: false,
onlyProperties: undefined, // string | RegExp | Array<string | RegExp>
excludeProperties: undefined,
debug: false, // or 'minimal' | 'verbose'
strict: false,
customUnits: undefined, // e.g. { cqh: 'vh', cqw: 'vw' }
browserslist: false, // true | browserslist query — auto-skip if all targets support dvh
fastSkip: false, // skip files without viewport units via one regex scan
onTransform(meta) {
console.log(meta);
},
onComplete(stats) {
console.log(stats); // { declarations, atRules, skipped, timeMs }
},
});Option Details
strategy
How declaration fallbacks are produced (default: 'duplicate').
'duplicate' — the classic, zero-runtime approach. Inserts a static twin declaration before the original:
/* height: 100dvh → */
height: 100vh;
height: 100dvh;Old browsers keep the last value they understand (100vh); modern browsers use 100dvh. Nothing to ship, works everywhere. Best default for most projects.
'css-var' — dynamic fallback for legacy browsers. Rewrites each unit to a calc() over a CSS variable and injects a :root seed that upgrades to the real unit via @supports:
/* input */
.sheet { height: 100dvh; }
/* output */
:root { --pvf-dvh: 1vh }
@supports (height: 1dvh) { :root { --pvf-dvh: 1dvh } }
.sheet { height: calc(var(--pvf-dvh, 1vh) * 100); height: 100dvh; }On its own this behaves like duplicate (falls back to 1vh). Its power comes from pairing it with the runtime (below), which sets --pvf-dvh from the actual viewport in browsers that lack dvh — so 100dvh follows the live viewport instead of the static vh approximation that ignores mobile browser chrome.
The
--pvf-custom-property prefix is reserved by the plugin: declarations whose name starts with--pvf-are never transformed, and the runtime writes only those variables. Don't define your own--pvf-*properties.
At-rule params (@media (min-height: 100dvh)) always use the duplicate strategy, because var()/calc() are invalid in feature-query context.
The runtime (for strategy: 'css-var')
A dependency-free module (ESM: 2.9 KB raw / 1.0 KB gzip). Import it once at your app entry:
import { applyViewportVars } from 'postcss-viewport-fallback/runtime';
applyViewportVars(); // no-op in browsers that support dvhIn browsers with native dv* support it detects CSS.supports('height','1dvh') and does nothing (the @supports seed already handles them). In browsers without it, it measures the layout viewport (innerWidth/innerHeight), sets every --pvf-* variable to 1% of the live dimension, and honors vertical writing modes for vi/vb. Returns { update, destroy } for manual control and SPA teardown. SSR-safe (no-op without window).
Accuracy — honest limitations. Native sv*/lv* values cannot be reproduced exactly from JavaScript, so the runtime approximates:
dv*follow the live layout viewport — the closest JS can get to native behaviorsv*/lv*track the smallest/largest size seen so far in the current orientation (extremes reset on rotation), so they converge to native-like values only after the browser chrome has actually collapsed/expanded once- updates are frozen during pinch-zoom (
visualViewport.scale !== 1), which must not affect viewport units - a same-width height collapse >30% while an input is focused is treated as the on-screen keyboard and ignored, matching native
dv*semantics (heuristic — a deliberately resized window with a focused input can be misclassified)
Framework entry points:
// Vite / plain
import { applyViewportVars } from 'postcss-viewport-fallback/runtime';
applyViewportVars();
// Next.js — app/layout.tsx (client component) or a <Script> tag
'use client';
import { applyViewportVars } from 'postcss-viewport-fallback/runtime';
applyViewportVars();If your target browsers all support
dvh(or you're happy with the staticvhapproximation), stick with the defaultduplicatestrategy and skip the runtime entirely.
preserve
Keep the original modern declaration alongside the fallback (default: true).
Set to false to output only the fallback — useful when targeting only older browsers:
// Input: height: 100dvh;
// Output: height: 100vh; (original removed)
preserve: falseApplies to at-rules too: @media (min-height: 100dvh) is replaced by its vh fallback block.
Note:
replace: truestill works as a deprecated alias forpreserve: false.
includeCustomProps
Transform custom properties, e.g.:
--header-height: 100dvh;onlyProperties
Apply transformation only to selected properties. Accepts a string, a RegExp, or an array of both:
onlyProperties: ['height', /^(min|max)-height$/]
onlyProperties: 'height'excludeProperties
Skip transformation for selected properties. Same format as onlyProperties:
excludeProperties: [/^padding/, 'margin']At-rules participate in property filters under the names
@media,@supports,@container. For example,excludeProperties: ['@media']skips media query params, andonlyProperties: ['height']disables at-rule transformation entirely (the allowlist doesn't include@media).
browserslist
Auto-skip the entire plugin if all target browsers (resolved from your browserslist config) support dynamic viewport units. Requires caniuse-api as an optional dependency:
npm install caniuse-api --save-devbrowserslist: true // resolve targets from project browserslist config
browserslist: 'last 2 versions' // or pass a query directly
browserslist: ['chrome >= 120', 'safari >= 17']If caniuse-api is not installed, the plugin quietly keeps transforming as usual. An invalid query or broken browserslist config, however, throws at plugin init — misconfiguration is never silently ignored.
fastSkip
Skip whole files that contain no viewport units using a single regex scan over the raw source, instead of visiting every declaration (default: false):
fastSkip: trueWarning: do not enable when an earlier plugin in the same PostCSS pipeline generates viewport units that are not present in the source file — e.g. Tailwind producing
h-dvhutilities from@tailwind utilities. The scan only sees the original source.
debug
Controls logging output:
false— no output (default)trueor'verbose'— per-transform warnings + summary with timing'minimal'— summary only (e.g.,"3 declarations, 1 at-rules transformed (0.42ms)")
Output is emitted via PostCSS result.warn(), so it integrates with postcss-reporter and other PostCSS tooling.
strict
Throws an error with source position when dynamic viewport units are found. Useful for CI pipelines to enforce that all viewport units have been manually reviewed.
Strict throws exactly where a transform would otherwise happen — declarations and at-rules that are skipped by excludeProperties/onlyProperties, custom properties without includeCustomProps, and idents that merely contain a unit substring (e.g. var(--dvh-color)) do not trigger it.
customUnits
Add custom unit-to-fallback mappings beyond the built-in ones:
customUnits: { cqh: 'vh', cqw: 'vw' }Custom units are merged with built-in units. You can also override built-in mappings. Chains are allowed (cqh → svh → vh produces progressive fallbacks); mapping cycles (e.g. { dvh: 'svh', svh: 'dvh' }) are rejected at plugin init.
onTransform(meta)
Callback fired for every transformation (declarations and at-rules).
onComplete(stats)
Callback fired after processing with statistics:
onComplete(stats) {
console.log(stats.declarations); // fallback declarations added
console.log(stats.atRules); // at-rule fallbacks added
console.log(stats.skipped); // dedup skips
console.log(stats.timeMs); // processing time in ms
}Control Comments
Disable transformation for a specific declaration by placing a comment before it:
.app {
/* postcss-viewport-fallback: off */
height: 100dvh; /* will NOT get a fallback */
width: 50dvw; /* will get a fallback */
}Or disable a whole range with disable / enable (applies to all following siblings and cascades into nested blocks):
/* postcss-viewport-fallback: disable */
.hero { height: 100dvh; } /* skipped */
@media (min-height: 50dvh) { .x { width: 10dvw; } } /* skipped, including contents */
/* postcss-viewport-fallback: enable */
.app { height: 100dvh; } /* gets a fallback */Supported units
All small (sv*), large (lv*), and dynamic (dv*) viewport units from CSS Values 4:
| Modern units | Fallback | | ------------------- | -------- | | dvw, svw, lvw | vw | | dvh, svh, lvh | vh | | dvi, svi, lvi | vi | | dvb, svb, lvb | vb | | dvmin, svmin, lvmin | vmin | | dvmax, svmax, lvmax | vmax |
Matching is case-insensitive (100DVH → 100vh), per CSS spec.
Honest note on
vi/vbfallbacks: logical viewport units (vi,vb) shipped in browsers at roughly the same time as their dynamic variants (Chromium 108, Safari 15.4) — a browser missingdvialmost certainly also missesvi. The practical value of this plugin is in thevh/vw/vmin/vmaxfallbacks;vi/vbmappings are provided for completeness.
Comparison
| | postcss-viewport-fallback | postcss-100vh-fix | postcss-viewport-unit-fallback | LightningCSS | postcss-preset-env |
| --- | :---: | :---: | :---: | :---: | :---: |
| All 18 sv*/lv*/dv* units | ✅ | ❌ (100vh only) | ⚠️ height units only | ❌ | ❌ |
| dvw/width & logical units | ✅ | ❌ | ❌ | ❌ | ⚠️ vi/vb only |
| Inside calc()/min()/clamp() | ✅ | ❌ | ❌ | — | — |
| @media/@supports/@container | ✅ | ❌ | ❌ | — | — |
| Preserves !important | ✅ | ✅ | ❌ (bug) | — | — |
| Dynamic runtime fallback option | ✅ (css-var) | ❌ | ❌ | ❌ | ❌ |
| browserslist auto-skip | ✅ | ❌ | ❌ | ✅ (targets) | ✅ (stage) |
| Actively maintained | ✅ | ⚠️ | ❌ (2023) | ✅ | ✅ |
- LightningCSS does not lower viewport units and won't — the maintainer's position is that
dvw → vwisn't a correct static lowering and a proper polyfill needs JavaScript (which is exactly whatstrategy: 'css-var'+ the runtime provides). - Autoprefixer only adds vendor prefixes; units are not prefixable, so it never touches them.
- postcss-100vh-fix solves the older iOS
-webkit-fill-availableproblem (height-only, breaks incalc()); it's orthogonal, not advhfallback.
Gotchas worth knowing
Being the viewport-units tool means being honest about their sharp edges — a fallback plugin can't fix these, but you should know them:
dvhdoesn't react to the on-screen keyboard. By default the dynamic viewport ignores the virtual keyboard. Opt in with<meta name="viewport" content="interactive-widget=resizes-content">(Chromium-only today).dvhcan jank. Some browsers debounce dynamic viewport updates rather than tracking at 60fps. For sticky/animated elements,svh(smallest viewport) is often the calmer choice thandvh.svhvsdvh:svhassumes browser chrome is visible (safe, never clipped);dvhfollows the live viewport (uses all space, but shifts). Picksvhfor guaranteed-visible content,dvhfor full-bleed.- Safari 15.6 (macOS) has a known bug where
dvhrenders larger than expected (WebKit #242758).
Installation
npm install postcss-viewport-fallback --save-devUsage (PostCSS Config)
import viewportFallback from 'postcss-viewport-fallback';
export default {
plugins: [viewportFallback()],
};Usage with Frameworks
Tailwind CSS
Tailwind has declined to emit vh fallbacks for its dvh utilities — this plugin is the answer. Order matters: list viewportFallback after Tailwind so it transforms the generated h-dvh / min-h-dvh / max-h-dvh output:
// postcss.config.js — Tailwind v3
export default {
plugins: {
tailwindcss: {},
'postcss-viewport-fallback': {},
},
};// Tailwind v4 (@tailwindcss/postcss)
export default {
plugins: {
'@tailwindcss/postcss': {},
'postcss-viewport-fallback': {},
},
};Because Tailwind generates utilities during the build (they aren't in your source), leave fastSkip off for the Tailwind entry — the raw-source scan wouldn't see them.
Vue / Nuxt
Works with <style> blocks in .vue files without extra configuration — vue-loader and Vite handle PostCSS natively.
Svelte / SvelteKit
Works with <style> blocks in .svelte files. Vite and svelte-preprocess apply PostCSS automatically.
styled-components / Emotion (CSS-in-JS)
For template literal CSS-in-JS, configure postcss-jsx as a syntax plugin:
import postcssJsx from 'postcss-jsx';
export default {
plugins: [viewportFallback()],
syntax: postcssJsx,
};HTML inline styles
For <style> tags in .html files, use postcss-html:
import postcssHtml from 'postcss-html';
export default {
plugins: [viewportFallback()],
syntax: postcssHtml,
};Development
npm test # vitest suite
npm run test:coverage # suite + v8 coverage report
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm run build # tsup → dist (esm + cjs + d.ts/d.cts)
npm run check:package # publint + arethetypeswrong (package exports health)
npm run bench # quick perf benchmark on 20k generated rulesCI runs lint, typecheck, tests (Node 20/22/24), build, and package checks on every push/PR. Coverage is reported on the Node 22 job.
Releases
Fully automated with semantic-release: every push to main with Conventional Commits (fix: → patch, feat: → minor, BREAKING CHANGE → major) runs CI and, if a release is due, bumps the version, updates CHANGELOG.md, tags a GitHub release, and publishes to npm with provenance — no manual steps.
License
MIT License © 2025 Maksim Kravtsov
