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.
By Afeez Adebayo, Marketing, SEO & Growth Expert with 10+ years of experience.
Created 27 October 2026. Updated 27 October 2026. Free to use under CC BY 4.0.
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
- 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.
- Never edit vendor files. Do not change
node_modules, vendor folders or CDN copies. A utility class such as Bootstrap's.vh-100is overridden from your own CSS. - 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. - Batches stay small. At most 1 component or 20 declarations per batch (defaults), each shown as a diff.
- Keep what works. Keep the plain
vhline as the first declaration. Never delete it. - 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.
- Refuses to: use
dvhinfont-size,line-heightor spacing, claim the on-screen keyboard is handled bydvh, addviewport-fit=coverwithout 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?" |
Raw SKILL.md file
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 -40Read 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
.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
- Run
vu_check.pyagain. Warnings must be 0, or each is listed with a reason. - Ask the user to run the device probe (Templates) with the bars expanded, then again after scrolling until they retract.
svhandlvhmust stay the same.dvhmust move between them.vhmust matchlvh. Record the numbers. - Check each changed element on the device in portrait and landscape.
- On a form screen, open the keyboard and focus the lowest field.
- Check for horizontal scroll with
flexbox-grid-layout.md. - If no device is available, write "not verified on a device" in the report. Do not claim a fix.
- 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 infoThe .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
| 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.
- Checker warnings are 0, or each is listed with a reason.
- Every changed height keeps a plain
vhline before itssvh,lvhordvhline. - No
font-size,line-height, margin, padding or gap uses a dynamic unit. - Page sections use
min-height, andheightappears only on shells with their own scroll. - Every
viewport-fit=coverhasmax(N, env(...))padding on each fixed edge element. - The probe numbers are in the report, or the report says "not verified on a device".
- Every changed line appears in the report with file, line, issue and fix.
- 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
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
vhline, never usingdvhin 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.
| Item | Tokens | Status |
|---|---|---|
| Description (always loaded) | 108 | Estimate |
| SKILL.md body (when the skill fires) | 4,579 | Estimate |
references/patterns.md, read only when rewriting a component | 502 | Estimate |
references/appendix.md, read only to check a claim | 1,020 | Estimate |
| Typical run (description, body, checker output) | 5,009 | Estimate |
| Cheaper path (checker, fix flagged lines, probe, no references) | 4,977 | Estimate |
| Worst case (both references, 40 lines of checker output) | 7,819 | Estimate |
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.
Related skills
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
Why does 100vh cut off my content on mobile?
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.
What is the difference between svh, lvh and dvh?
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.
Should I replace every vh with dvh?
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.
Does dvh fix the on-screen keyboard problem?
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.
Why does env(safe-area-inset-top) with a fallback return 0?
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.
How do I keep a fallback for browsers without svh?
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.
How many tokens does the Viewport Units skill use?
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.
