---
name: viewport-units
description: "Audits and fixes CSS viewport units on mobile: 100vh heroes, modals, loaders and sticky footers hidden behind browser bars, choosing svh, lvh or dvh, dvh used in font sizes and spacing, env(safe-area-inset) with viewport-fit=cover, and the on-screen keyboard. Runs a read-only checker and gives a probe to run on a real device. Use for the 100vh mobile bug, dvh, svh, lvh, full-height layouts and notch safe areas. Do NOT use for 100vw overflow, fluid type, container queries or page breakpoints."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/css-layout/viewport-units/
  category: frontend-development
  subcategory: css-layout
  version: "1.0.0"
  created: "2026-10-27"
  updated: "2026-10-27"
---

# Viewport Units: Fix 100vh, svh, lvh and dvh on Mobile Browsers

An agent works on viewport units in this order: run a read-only checker over the stylesheets and HTML, decide for each full-height element whether it must stay visible or should follow the browser bars, write the new unit with a plain `vh` fallback line, handle safe areas and the keyboard by rule, then ask the user to run a probe on a real phone. A desktop browser cannot show the difference between the units, so the agent never claims a fix it has not seen on a device.

It works on plain CSS, bundler output and `<style>` blocks. Measurements and sources sit in `references/appendix.md`, component recipes in `references/patterns.md`.

## When to use this skill, and when not to

Use it when a full-height element is cut off or pushed down on a phone, when a footer or modal button is hidden behind the browser bars, when `dvh` was added and text or spacing now jumps, or when content runs under a notch.

| Request | Use instead |
|---|---|
| `100vw`, horizontal scroll, `1fr` blowouts | `flexbox-grid-layout.md` |
| Fluid font sizes and spacing with `clamp()` | `fluid-type-spacing.md` |
| A component that responds to its container | `container-queries.md` |
| Naming and structuring custom properties | `css-custom-properties.md` |
| Hero image loading speed, layout shift scores | Marketing skills `lcp-optimisation.md` and `cls-optimisation.md` |
| Keyboard-aware layouts that need `visualViewport` scripts | Out of scope. Step 6 states the rule |
| Page-level breakpoints | Out of scope. Keep `@media` |

## Guardrails

1. **Propose, then change.** Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Never edit vendor files.** Do not change `node_modules`, vendor folders or CDN copies. A utility class such as Bootstrap's `.vh-100` is overridden from your own CSS.
3. **No page text, no live sites, no secrets.** The checker reads CSS and viewport meta tags only. Do not fetch or measure pages you do not own. Stylesheets can carry API keys or tokens in `url(...)` values and comments: do not paste them into reports or prompts. The device probe returns four numbers and nothing else.
4. **Batches stay small.** At most 1 component or 20 declarations per batch (defaults), each shown as a diff.
5. **Keep what works.** Keep the plain `vh` line as the first declaration. Never delete it.
6. **Nothing is invented.** Browser versions and dates carry a source. Version-dependent facts carry **verify before relying on this**. Any unit choice not confirmed on a device is reported as unverified.
7. **Refuses to:** use `dvh` in `font-size`, `line-height` or spacing, claim the on-screen keyboard is handled by `dvh`, add `viewport-fit=cover` without padding on every fixed edge element, or report a fix as verified from a desktop browser.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Stylesheets and HTML | "List every stylesheet and the HTML that loads it, in load order." |
| Elements | "Which elements should fill the screen: a hero, a modal, a loader, an app shell, a drawer?" |
| Scrolling | "Can the page scroll while each of those is shown?" |
| Browsers | "Which browsers must work? The small, large and dynamic units shipped in Safari 15.4, Firefox 101 and Chrome 108 (web.dev). Do you need older ones?" |
| Edge to edge | "Is the site installed as an app, or does it use a notch or rounded-corner area?" |
| Device | "Which phone can you run a short probe on? Name the model and browser." |
| Approval | "May I edit on a branch in batches, showing each diff first?" |

## Workflow

### Step 0: Confirm scope

List the files and the elements in scope. Minified input is fine for the checker, but edits go in source files.

### Step 1: Run the checker

```
python3 scripts/vu_check.py page.css page.html | head -40
```

Read only its output. Do not read the script. Pass CSS and HTML together so the `viewport-fit` checks run. The last line gives counts. `--min-vh N` raises or lowers the plain `vh` threshold (default 50). `--legacy` adds `no-fallback` for browsers without the new units. `--strict` exits 1 on any warning.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `vh-tall` | warn | A plain `vh` of 50 or more on a height property resolves to the large viewport, so content can sit behind mobile browser bars | Step 3 and step 4 |
| `dv-shift` | warn | `dvh`, `dvb`, `dvmin` or `dvmax` in type or spacing changes while the user scrolls | Use `rem`, or `svh` if it must track height |
| `env-fallback` | warn | The fallback in `env(safe-area-inset-*, N)` never applies | `max(env(...), N)` (step 7) |
| `cover-no-inset` | warn | `viewport-fit=cover` with no `env(safe-area-inset-*)` anywhere | Add padding (step 7) or remove `cover` |
| `inset-no-cover` | info | `env(safe-area-inset-*)` is used but `cover` is absent | Nothing to fix. The browser insets the page itself |
| `no-fallback` | info | `svh`, `lvh` or `dvh` with no plain `vh` line, only with `--legacy` | Add the fallback line (step 4) |

A `vh` value is not flagged when the same selector and property also use `svh`, `lvh` or `dvh` in any rule or `@supports` block you passed. Values under 50 are ignored because small ratios do not hide content. The threshold is a default.

### Step 2: Know the units

| Unit | Meaning | Behaviour |
|---|---|---|
| `vh` | Equal to `lvh` | Ignores the browser bars when they are showing |
| `lvh` | Large viewport: bars retracted | Fixed. Content can be hidden behind expanded bars |
| `svh` | Small viewport: bars expanded | Fixed. Can leave spare room when the bars retract |
| `dvh` | Between `svh` and `lvh` | Moves as the bars move. Not updated at 60fps, and debounced in some browsers |

Viewport units ignore scrollbars, and the on-screen keyboard does not change them by default (MDN and web.dev, see Sources). On a desktop browser and on mobile emulation all four were equal to `innerHeight` (measured, Chromium 153).

### Step 3: Choose the unit for each element

| Element | Unit | Why |
|---|---|---|
| Hero or section that must be fully visible on load | `min-height: 100svh` | Never behind the bars. `min-height` lets content grow |
| Modal with a sticky header or footer | `max-height: calc(100svh - Npx)` | The footer stays visible |
| Loader or splash while the page cannot scroll | `100dvh` | The bars are not moving, so no jump |
| App shell that scrolls inside itself | `height: 100dvh` | Tracks the bars. Test for layout cost |
| Drawer or full-height menu | `100dvh`, or `svh` if it must never be hidden | The same trade-off |
| Anything below 50, such as `max-height: 40vh` | Leave as is | Ratios do not hide content |
| `font-size`, `line-height`, `margin`, `padding`, `gap` | Never `dvh` | Text and spacing shift during scroll |

These are defaults from the unit definitions. Confirm each on a device (step 8). If the user says an element behaves differently, follow the device.

### Step 4: Write the fallback

```css
.hero { min-height: 100vh; min-height: 100svh; }
```

The later line wins in browsers that know the unit. A browser that does not drops it and keeps the first line (measured: `height: 100vh; height: 100xyz` stayed 700px). Use `@supports (height: 100dvh) { ... }` when the override needs several properties. Never remove the first line.

| Rule | Reason |
|---|---|
| Page sections use `min-height` | `height: 100svh` with 1,000px of content stayed 700px and overflowed (measured) |
| Use `height` only on a shell with its own scroll area | Content needs somewhere to scroll |
| Give the scroll area `min-height: 0` in a flex or grid shell | Otherwise it will not shrink. See `flexbox-grid-layout.md` |

### Step 5: Remove dynamic units from type and spacing

Every `dv-shift` line moves to `rem`, or to `svh` when the value must follow height. A `calc(1rem + 2dvh)` heading went from 30px to 28px when the height changed 700px to 600px (measured). That change happens in step with the browser bars on a phone.

### Step 6: Keyboard

| Rule | Reason |
|---|---|
| Do not size a form screen in `dvh` to avoid the keyboard | The keyboard does not change viewport units by default |
| Fixed headers and footers on form screens need a real-device test | csswg-drafts issue 10464: on iOS Safari 17.4.1 the layout broke when the keyboard opened, and `vh`, `dvh`, `position: fixed` and `visualViewport` did not fix it |
| Do not rely on `interactive-widget` in Safari | A commenter on that issue says Safari does not implement it. **Verify before relying on this** |

### Step 7: Safe areas

| Rule | Reason |
|---|---|
| Add `viewport-fit=cover` only when every fixed or edge element gets padding | With `cover`, content can sit under the notch and rounded corners (WebKit blog, 22 September 2017) |
| Pad with `max(12px, env(safe-area-inset-bottom))`, inside `@supports (padding: max(0px))` | The `env()` fallback never applies. `env(safe-area-inset-top, 20px)` gave 0px (measured, Chromium 153) |
| Without `cover`, add nothing | The browser insets the page and fills the gap with the page background colour |

### Step 8: Verify

1. Run `vu_check.py` again. Warnings must be 0, or each is listed with a reason.
2. Ask the user to run the device probe (Templates) with the bars expanded, then again after scrolling until they retract. `svh` and `lvh` must stay the same. `dvh` must move between them. `vh` must match `lvh`. Record the numbers.
3. Check each changed element on the device in portrait and landscape.
4. On a form screen, open the keyboard and focus the lowest field.
5. Check for horizontal scroll with `flexbox-grid-layout.md`.
6. If no device is available, write "not verified on a device" in the report. Do not claim a fix.
7. Run the existing test suite if there is one.

### Step 9: Report

Use the report template. A short list of file, line, issue and fix.

## Worked example

An invented stylesheet with eleven rule blocks and an HTML page with `viewport-fit=cover`. The agent runs the checker.

```
page.css:1  warn  vh-tall  .hero: min-height: 100vh resolves to the large viewport, so content can sit behind mobile browser bars. Use svh or dvh (step 3)
page.css:2  warn  vh-tall  .loader: height: 100vh resolves to the large viewport, so content can sit behind mobile browser bars. Use svh or dvh (step 3)
page.css:3  warn  vh-tall  .modal: max-height: calc(100vh - 2rem) resolves to the large viewport, so content can sit behind mobile browser bars. Use svh or dvh (step 3)
page.css:8  warn  dv-shift  h2: font-size: calc(1rem + 2dvh) follows the dynamic viewport and changes while the user scrolls. Use rem, or svh if it must track height
page.css:9  warn  env-fallback  .card: padding-top: env(safe-area-inset-*) is always defined, so its fallback never applies. Use max(env(...), N)
page.css:9  warn  dv-shift  .card: margin-block: 5dvh follows the dynamic viewport and changes while the user scrolls. Use rem, or svh if it must track height
page.html:3  warn  vh-tall  .a: height: 100vh resolves to the large viewport, so content can sit behind mobile browser bars. Use svh or dvh (step 3)
0 errors, 7 warnings, 0 info
```

The `.shell` rule is not listed because `height: 100dvh` follows its `100vh` line. `.panel` is not listed because an `@supports` block overrides it. `.menu` at 40vh is under the threshold. The user confirms the page cannot scroll while the loader shows, and that the modal footer must stay visible.

One batch (`page.css`, `page.html`): `.hero` gets `min-height: 100vh; min-height: 100svh`, `.loader` gets a `100dvh` line after its `100vh`, `.modal` gets `max-height: calc(100svh - 2rem)` after its `calc(100vh - 2rem)`, `h2` moves to `1.5rem` (for fluid sizes see `fluid-type-spacing.md`), `.card` pads with `max(20px, env(safe-area-inset-top))` and `margin-block: 2rem`, and the `.a` height gets its `svh` line. The checker then reports 0 warnings. In Chromium 153 every unit measured 700px at a 700px viewport and 600px at 600px, so the desktop run could not show the effect. The agent reports the batch as "applied, not verified on a device" and asks the user to run the probe.

## Templates

### Device probe (step 8)

```js
(() => { const o = {};
  for (const u of ['vh', 'lvh', 'svh', 'dvh']) { const d = document.createElement('div');
    d.style.cssText = 'position:fixed;visibility:hidden;height:100' + u; document.body.append(d); o[u] = d.offsetHeight; d.remove(); }
  o.innerHeight = innerHeight; return o; })()
```

It adds and removes an invisible box and returns five numbers. It reads no page text, cookies or storage. Run it on the user's own device, with remote debugging or a bookmarklet, and only on pages the user owns.

### Report

```
Viewport units report: <page>, <date>
Elements: <list>. Browsers: <list>. Device: <model, browser, or "none">.
Checker before: N warnings. After: N warnings.
Changes: <file:line  issue  fix>   (one line each)
Probe, bars expanded: vh N, lvh N, svh N, dvh N.  Bars retracted: vh N, lvh N, svh N, dvh N.
Left as is: <warning  reason>
Not verified on a device: <items>
```

### Starting prompt

```
Use the viewport-units skill. Stylesheets and HTML: <list>. Full-height elements: <list>.
The page <can | cannot> scroll while they are shown. Browsers: <list>. Device for the probe: <model>.
Edit on a branch in batches of one component or 20 declarations and show each diff first.
```

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| `100vh` on a hero, loader or modal | `vh-tall`. On a phone the footer or button sits behind the bars | `svh` or `dvh` with a `vh` fallback line |
| `dvh` in `font-size` or spacing | `dv-shift`. Text or gaps jump while scrolling | `rem`, or `svh` |
| `height: 100svh` on a section | Content overflows its box at the bottom | `min-height: 100svh` |
| `viewport-fit=cover` with no padding | `cover-no-inset`. Content under the notch | `max(N, env(...))` padding |
| Using `dvh` to dodge the keyboard | A fixed footer jumps or hides when the keyboard opens | Test on a real device and keep the field scrollable |

## Quality bar

Run every item before handing over.

1. Checker warnings are 0, or each is listed with a reason.
2. Every changed height keeps a plain `vh` line before its `svh`, `lvh` or `dvh` line.
3. No `font-size`, `line-height`, margin, padding or gap uses a dynamic unit.
4. Page sections use `min-height`, and `height` appears only on shells with their own scroll.
5. Every `viewport-fit=cover` has `max(N, env(...))` padding on each fixed edge element.
6. The probe numbers are in the report, or the report says "not verified on a device".
7. Every changed line appears in the report with file, line, issue and fix.
8. The report lists what was not tested, such as other models, landscape and the keyboard.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from summaries of the pages I read, so they carry two layers of estimate.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 108 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 4,579 | Estimate | When the skill fires |
| C. `references/patterns.md` | 502 | Estimate | Only when rewriting a hero, modal, loader, shell or bottom bar |
| C. `references/appendix.md` | 1,020 | Estimate | Only to check a claim |
| Script output | 322 | Estimate | Every run. Checker output is capped with `head -40` |
| D. Typical run (A + B + script output) | 5,009 | Estimate | Common path: fix flagged lines, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 7,819 | Estimate | Many components, claim checks |
| E. Cheaper path | 4,977 | Estimate | Run the checker, fix flagged lines, give the user the probe, skip the references |

Rival sizes, estimated: impertio-studio frontend-errors-units-rendering-viewport, roughly 350 words visible (about 470 tokens) and longer behind a truncated page, no scripts; open-design responsive-design about 300 words (roughly 405 tokens) with a link to `references/details.md`; wpank responsive-design, not sized because only a listing page could be read. This skill costs more to load than the two I could size. What it adds is a checker for the failures that need a fix, rules for type, keyboard and safe areas, and a probe that makes the user's device the test. On a 139,846-character stylesheet the checker printed 3 lines in 115ms. On a stylesheet under about 100 lines, read the file and skip the checker.

## Handoffs

`flexbox-grid-layout.md` covers `100vw` overflow and shrinking inside shells. `fluid-type-spacing.md` covers the replacement for `dvh` text. `container-queries.md` covers sizing by container. `css-custom-properties.md` covers storing the fallback pair in a token.

## Appendix

Measurements, unit facts and source claims are in `references/appendix.md`. Component recipes are in `references/patterns.md`.

## Sources

Last verified 27 October 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- MDN, CSS length units (page last modified 8 July 2026): https://developer.mozilla.org/en-US/docs/Web/CSS/length
- web.dev, The large, small and dynamic viewport units: https://web.dev/blog/viewport-units
- WebKit bug 261185, svh and dvh equal when the Safari tab bar is hidden: https://bugs.webkit.org/show_bug.cgi?id=261185
- csswg-drafts issue 10464, iOS Safari keyboard and fixed layouts: https://github.com/w3c/csswg-drafts/issues/10464
- WebKit blog, Designing Websites for iPhone X (22 September 2017): https://webkit.org/blog/7929/designing-websites-for-iphone-x/
- Ahmad Shadeed, The Layout Maestro (8 July 2023): https://ishadeed.com/article/new-viewport-units/
- impertio-studio, frontend-errors-units-rendering-viewport. Rival implementation: https://www.skills.sh/impertio-studio/frontend-design-claude-skill-package/frontend-errors-units-rendering-viewport
- open-design, responsive-design. Rival implementation: https://open-design.ai/skills/responsive-design/
- wpank, responsive-design (via getclawkit). Rival implementation: https://getclawkit.com/skills/official-wpank-responsive-design

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/css-layout/viewport-units/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
