CSS and Layout skill 5 of 5

Viewport Units SKILL.md: Fix 100vh, dvh and svh on Mobile

The Viewport Units skill makes Claude, Cursor or another AI agent find 100vh heroes, modals and loaders that sit behind mobile browser bars, pick svh, lvh or dvh for each element, and keep dvh out of text and spacing. A read-only checker finds the problems, and a probe on a real phone confirms the fix.

viewport-units/SKILL.md, version 1.0.0, 3,195 words, plus scripts/vu_check.py.

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.

RequestUse instead
100vw, horizontal scroll, 1fr blowoutsflexbox-grid-layout.md
Fluid font sizes and spacing with clamp()fluid-type-spacing.md
A component that responds to its containercontainer-queries.md
Naming and structuring custom propertiescss-custom-properties.md
Hero image loading speed, layout shift scoresMarketing skills lcp-optimisation.md and cls-optimisation.md
Keyboard-aware layouts that need visualViewport scriptsOut of scope. Step 6 states the rule
Page-level breakpointsOut 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.

InputAsk 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?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as viewport-units/SKILL.md.

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

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.

CodeLevelMeaningFix
vh-tallwarnA plain vh of 50 or more on a height property resolves to the large viewport, so content can sit behind mobile browser barsStep 3 and step 4
dv-shiftwarndvh, dvb, dvmin or dvmax in type or spacing changes while the user scrollsUse rem, or svh if it must track height
env-fallbackwarnThe fallback in env(safe-area-inset-*, N) never appliesmax(env(...), N) (step 7)
cover-no-insetwarnviewport-fit=cover with no env(safe-area-inset-*) anywhereAdd padding (step 7) or remove cover
inset-no-coverinfoenv(safe-area-inset-*) is used but cover is absentNothing to fix. The browser insets the page itself
no-fallbackinfosvh, lvh or dvh with no plain vh line, only with --legacyAdd 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

UnitMeaningBehaviour
vhEqual to lvhIgnores the browser bars when they are showing
lvhLarge viewport: bars retractedFixed. Content can be hidden behind expanded bars
svhSmall viewport: bars expandedFixed. Can leave spare room when the bars retract
dvhBetween svh and lvhMoves 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

ElementUnitWhy
Hero or section that must be fully visible on loadmin-height: 100svhNever behind the bars. min-height lets content grow
Modal with a sticky header or footermax-height: calc(100svh - Npx)The footer stays visible
Loader or splash while the page cannot scroll100dvhThe bars are not moving, so no jump
App shell that scrolls inside itselfheight: 100dvhTracks the bars. Test for layout cost
Drawer or full-height menu100dvh, or svh if it must never be hiddenThe same trade-off
Anything below 50, such as max-height: 40vhLeave as isRatios do not hide content
font-size, line-height, margin, padding, gapNever dvhText 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

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

RuleReason
Page sections use min-heightheight: 100svh with 1,000px of content stayed 700px and overflowed (measured)
Use height only on a shell with its own scroll areaContent needs somewhere to scroll
Give the scroll area min-height: 0 in a flex or grid shellOtherwise 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

RuleReason
Do not size a form screen in dvh to avoid the keyboardThe keyboard does not change viewport units by default
Fixed headers and footers on form screens need a real-device testcsswg-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 SafariA commenter on that issue says Safari does not implement it. Verify before relying on this

Step 7: Safe areas

RuleReason
Add viewport-fit=cover only when every fixed or edge element gets paddingWith 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 nothingThe 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)

(() => { 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

MistakeHow to spot itDo instead
100vh on a hero, loader or modalvh-tall. On a phone the footer or button sits behind the barssvh or dvh with a vh fallback line
dvh in font-size or spacingdv-shift. Text or gaps jump while scrollingrem, or svh
height: 100svh on a sectionContent overflows its box at the bottommin-height: 100svh
viewport-fit=cover with no paddingcover-no-inset. Content under the notchmax(N, env(...)) padding
Using dvh to dodge the keyboardA fixed footer jumps or hides when the keyboard opensTest 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.

ItemTokensStatusRead condition
A. Description (always loaded)108EstimateEvery session with the skill installed
B. SKILL.md body4,579EstimateWhen the skill fires
C. references/patterns.md502EstimateOnly when rewriting a hero, modal, loader, shell or bottom bar
C. references/appendix.md1,020EstimateOnly to check a claim
Script output322EstimateEvery run. Checker output is capped with head -40
D. Typical run (A + B + script output)5,009EstimateCommon path: fix flagged lines, no references
D. Worst case (A + B + both references + 40 lines of output)7,819EstimateMany components, claim checks
E. Cheaper path4,977EstimateRun 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.

What this skill does

On a phone, 100vh is the height of the viewport with the browser bars retracted. MDN says vh is equivalent to lvh. When the address bar is showing, a full-height hero, a modal footer or a loader icon ends up partly behind it. The newer svh, lvh and dvh units fix that, and they bring their own failures: dvh in a font size makes text shift while the user scrolls, and none of them reacts to the on-screen keyboard by default.

This skill gives the agent a fixed order. A read-only checker reports six kinds of problem. The agent picks a unit for each full-height element with a decision table, writes it after a plain vh fallback line, and applies rules for safe areas and the keyboard. Desktop Chromium returned the same height for all four units, so the skill asks you to run a five-number probe on a real phone before anything is called fixed.

Who it is for

Use it if a footer or button is cut off on a phone, a loader sits low, a hero is cropped on Android, text jumps after someone added dvh, or content runs under a notch. It suits developers who use Claude Code, Cursor or similar agents.

It is not for 100vw overflow, which Flexbox and Grid Layout covers, or fluid type, which belongs to Fluid Type and Spacing. It does not write keyboard-aware layouts that need visualViewport scripts.

What is inside

The file is 3,195 words (version 1.0.0) with one script and two reference files. It holds:

  • Seven guardrails, including never deleting the plain vh line, never using dvh in text or spacing, and never reporting a fix as verified from a desktop browser.
  • Seven inputs the agent must have first, with the exact question for each.
  • A workflow of steps 0 to 9, a table with one fix per checker code, a unit table, and a decision table for hero, modal, loader, shell and drawer.
  • One standard-library script, scripts/vu_check.py, with six problem codes.
  • A worked example with real output (7 warnings before, 0 after), run against Chromium 153.
  • A device probe, a report template, a starting prompt, five failure modes and an eight-item quality bar.
  • Two reference files: component recipes, and an appendix with 11 measurements and the browser facts from MDN, web.dev, WebKit and the CSS working group.

The token costs below are estimates from the skill’s own count: words times 1.35 for prose and characters divided by 3.5 for code and tables. They are not from a model tokeniser.

ItemTokensStatus
Description (always loaded)108Estimate
SKILL.md body (when the skill fires)4,579Estimate
references/patterns.md, read only when rewriting a component502Estimate
references/appendix.md, read only to check a claim1,020Estimate
Typical run (description, body, checker output)5,009Estimate
Cheaper path (checker, fix flagged lines, probe, no references)4,977Estimate
Worst case (both references, 40 lines of checker output)7,819Estimate

This skill costs more to load than the two rivals I could size, which are roughly 400 to 470 tokens for the visible SKILL.md. What it adds is a checker, rules for type, keyboard and safe areas, and a probe that makes your phone the test. On a 139,846-character stylesheet the checker printed 3 lines in 115 milliseconds.

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as viewport-units/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the script beside your code. Then send the starting prompt from the Templates section.

Before you install. Read the script first. It reads CSS and HTML files and writes nothing. The skill tells the agent to edit only on a branch in batches you approve, and the probe returns five numbers and no page text. Install skills only from sources you trust.

A worked example

An invented stylesheet has eleven rule blocks and a page with viewport-fit=cover. The checker reports 7 warnings: three 100vh heights on a hero, a loader and a modal, a dvh font size, a dvh margin, an env() fallback that never applies, and a 100vh in the page itself.

The agent leaves the rules that already have a fallback pair alone. It asks whether the page can scroll while the loader shows, and the answer picks dvh for the loader and svh for the modal. One batch writes each new unit after its plain vh line, moves the text to rem and pads with max(). The checker then reports 0 warnings. Chromium returned 700px for every unit, so the agent reports the batch as applied and not verified on a device, and asks you to run the probe.

Common mistakes

The skill is built around five ways this goes wrong:

  • 100vh on a hero, loader or modal. The element sits behind the bars. The skill uses svh or dvh after a vh fallback.
  • dvh in font size or spacing. Text and gaps jump while scrolling. The skill uses rem.
  • A fixed height on a section. Content overflows the box. The skill uses min-height.
  • viewport-fit=cover with no padding. Content sits under the notch. The skill pads with max() and env().
  • dvh to dodge the keyboard. Viewport units ignore it by default. The skill requires a real-device test.

Flexbox and Grid Layout covers 100vw overflow and shrinking inside shells, and Fluid Type and Spacing covers the replacement for dvh text. Container Queries covers sizing by container, and CSS Custom Properties covers storing the fallback pair in a token. For loading speed and layout shift, see the marketing skills on LCP optimisation and CLS optimisation. Browse the CSS and Layout set, the Frontend Development category or the Skills-md library.

Frequently asked questions

On a phone, 100vh is the height with the browser bars retracted. MDN says vh is equivalent to lvh. With the address bar showing, the bottom of a full-height element sits behind the bars. Use svh for content that must stay visible and dvh when it should follow the bars, and keep a vh line first as a fallback.

The small viewport (svh) is sized with the bars showing and the large viewport (lvh) with them retracted. Both stay fixed. dvh moves between the two as the bars move, and web.dev notes it does not update at 60fps. Safari 15.4, Firefox 101 and Chrome 108 were the first to ship them.

No. dvh follows the bars while the user scrolls, so in font sizes or spacing it makes content shift. Ahmad Shadeed rejected it for text for that reason. In Chromium 153 a heading in dvh changed from 30px to 28px when the height changed. Use svh for content that must be visible and rem for text.

No. web.dev says the keyboard does not change viewport units by default. A report on the CSS working group tracker found a fixed header and footer breaking in iOS Safari 17.4.1 when the keyboard opened, and vh, dvh and visualViewport attempts did not fix it. Test form screens on a real device.

The safe-area values are always defined, so the fallback never applies. In Chromium 153, env(safe-area-inset-top, 20px) gave 0px, while max(env(safe-area-inset-top), 20px) gave 20px. Use max() for a minimum padding, and add viewport-fit=cover only when every edge element has that padding.

Write the plain vh line first and the new unit after it. A browser that does not know the unit drops that line and keeps the first. In Chromium 153, a height of 100vh followed by an unknown unit stayed at 700px. Use an @supports block when the override needs several properties.

The description is about 108 tokens and loads in every session. The SKILL.md body is about 4,579 tokens when the skill fires. A typical run is about 5,009 tokens with checker output, the cheapest path is about 4,977 and the worst case is about 7,819. All figures are estimates, not from a model tokeniser.

Changelog

  • 1.0.0, 27 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.