| name | vanilla-webpage |
| description | Build webpages with vanilla HTML/CSS/JS — no frameworks, no build steps. Covers modern CSS (nesting, @layer, :has, clamp, container queries), vanilla JS, design tokens, accessibility, responsive design.
|
| allowed-tools | Read Write Edit Bash WebSearch WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs mcp__exa__web_search_exa mcp__exa__web_fetch_exa mcp__playwright__browser_navigate mcp__playwright__browser_snapshot |
Vanilla Webpage Skill
Build webpages with vanilla HTML, CSS, and JavaScript. No frameworks, no build steps, no bloat. Modern CSS is powerful enough. Patterns are reference material, not a menu — use the Ponytail Ladder to decide what you actually need.
Quick Routing
| I need to... | Go to |
|---|
| Decide if I need a framework | §1 Philosophy + §2 Decision Framework |
| Write semantic HTML | §4 HTML Patterns |
| Style with CSS (custom properties, nesting, @layer) | §5 CSS Patterns |
| Use modern CSS (clamp, :has, container queries) | §5 CSS Patterns |
| Add interactivity with JS | §6 JavaScript Patterns |
| Choose a classless CSS framework | §7 Framework Allowlist |
| Avoid common mistakes | §8 Anti-Patterns |
| Check design tokens and spacing | §9 Design Quick Reference |
| Verify accessibility | §10 Accessibility Minimums |
| Additional CSS/JS patterns | §10b Additional Techniques |
| Browse working code examples | §11 Cookbook Reference |
| Research framework docs or CSS techniques | §12 Documentation & Research Access |
§12 Documentation & Research Access
Always research before writing code. Use MCP tools first, fall back to web search.
MCP Tools (Preferred)
| Tool | Use for | How |
|---|
| Context7 | Framework documentation | mcp__context7__resolve-library-id → mcp__context7__query-docs |
| Exa | Web search for techniques | mcp__exa__web_search_exa with semantic query |
| Exa | Fetch specific URLs | mcp__exa__web_fetch_exa with URL list |
| Playwright | Visual verification | mcp__playwright__browser_navigate → mcp__playwright__browser_snapshot |
Context7 Workflow (for framework docs)
1. resolve-library-id("basecoat ui") → returns library ID
2. query-docs(libraryId, "button component usage") → returns docs
Use for: Pico CSS, Basecoat UI, Flowbite, UIkit, HTMX, or any framework in §7.
Exa Workflow (for technique research)
1. web_search_exa("CSS container query examples 2025") → returns articles
2. web_fetch_exa(["https://example.com/article"]) → returns full content
Use for: Modern CSS techniques, browser support data, best practices, code examples from blogs.
Fallback: Native WebSearch/WebFetch
If MCP tools are unavailable:
1. WebSearch("technique name + year") → find articles
2. WebFetch(url) → extract content
When to Research
- Before using a framework from §7: Fetch its latest docs via Context7
- Before using a cutting-edge CSS feature: Verify browser support via Exa or WebSearch
- When unsure about syntax: Search for working examples
- When debugging: Search for error messages and solutions
§1 Philosophy — The Ponytail Ladder
Stop at the first rung that holds:
- Does this need to exist at all? Speculative need = skip it. (YAGNI)
- Already in this codebase? Reuse existing patterns. Look before you write.
- Stdlib does it? Use it.
- Native platform feature covers it?
<dialog> for modals, <details> for accordions, CSS over JS.
- Already-installed dependency solves it? Use it.
- Can it be one line? One line.
- Only then: the minimum code that works.
§2 Decision Framework
Can semantic HTML do it? → Yes → Done
No → Can 20 lines of vanilla CSS do it? → Yes → Write the CSS
No → Does a classless CSS framework solve it in one <link>? → Yes → Use it
No → Is the problem interactivity? → Use vanilla JS (or HTMX for complex cases)
No → OK, consider server-driven interactivity or declarative DOM behavior under 15KB
§3 File Structure Convention
- Simple page: single
index.html (embedded <style> in <head>, <script> before </body>)
- Multi-section:
index.html + style.css + script.js at root
- Split files when they exceed ~400 lines
- No
src/ / dist/ unless there's a build step (YAGNI)
- No
package.json, no node_modules for static pages
- Reference cookbook: Browse
cookbook/ before writing new CSS from scratch
Minimal HTML Boilerplate
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Title</title>
<style></style>
</head>
<body>
<main></main>
<script></script>
</body>
</html>
§4 HTML Patterns
Semantic Elements (always prefer)
<nav>, <main>, <article>, <section>, <aside>, <header>, <footer>
<form>, <fieldset>, <legend>, <label>
<button>, <input>, <select>, <textarea>
<details>/<summary> for accordions (no JS needed)
<dialog> for modals (no JS needed for the element itself)
<picture>/<source> for responsive images
Choosing Overlay Primitives
| Need | Use | Why |
|---|
| Modal (blocks page) | <dialog> | Focus trap, ::backdrop, ESC to close |
| Disclosure (optional visibility) | <details>/<summary> | No JS, semantic, SEO-friendly |
| Non-modal tooltip/menu | [popover] | Lightweight, no focus trap, positioning |
Dialog Element
<dialog id="note-dialog">
<form method="dialog">
<h2>Edit Note</h2>
<textarea></textarea>
<button>Save</button>
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
</form>
</dialog>
<script>
document.getElementById('note-dialog').showModal();
</script>
Details/Summary Accordion
<details>
<summary>Common Question</summary>
<p>Answer content here...</p>
</details>
Accessible Forms
<form>
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" inputmode="email" required>
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<button type="submit">Login</button>
</form>
Prefer HTML5 native validation (required, pattern, type) for simple cases. Use JS validation only for cross-field rules, custom error messages, or server-side confirmation.
ARIA Attributes
<button aria-expanded="false" aria-controls="menu" aria-label="Toggle menu">☰</button>
<nav aria-label="Main navigation" aria-current="page">
</nav>
<h2 id="section-title">Settings</h2>
<div aria-labelledby="section-title">...</div>
<img src="icon.svg" alt="" aria-hidden="true">
<img src="photo.jpg" alt="Description of image">
Rules: Use semantic HTML first. role is rarely needed (buttons, links, navigation have implicit roles). Only add ARIA when native HTML semantics don't convey the meaning.
HTML Tables
<table>
<thead>
<tr><th>Name</th><th>Email</th><th>Role</th></tr>
</thead>
<tbody>
<tr><td data-label="Name">Alex</td><td data-label="Email">alex@example.com</td><td data-label="Role">Developer</td></tr>
<tr><td data-label="Name">Jordan</td><td data-label="Email">jordan@example.com</td><td data-label="Role">Designer</td>
2 users total
@media (max-width: 600px) {
thead { display: none; }
tr { display: block; margin-bottom: 1rem; }
td { display: block; text-align: right; }
td::before { content: attr(data-label); float: left; font-weight: bold; }
}
Popover API (no JS needed for open/close)
<button popovertarget="my-popover">Open</button>
<div popover id="my-popover">Popover content</div>
Invoker Commands (commandfor)
<button commandfor="my-dialog" command="showModal">Open Dialog</button>
<dialog id="my-dialog">Content</dialog>
<button commandfor="my-dialog" command="close">Close</button>
<button commandfor="my-menu" command="togglepopover">Menu</button>
<div popover id="my-menu">Menu items</div>
- Declarative open/close for
<dialog> and <popover> without JavaScript
command="showModal" / command="close" for dialogs
command="togglepopover" / command="showpopover" / command="hidepopover" for popovers
- Chrome 133+, limited browser support
Data Attributes for Dynamic Content
<a href="#" data-name="Twitter" style="--color: #1da1f2">🐦</a>
<a href="#" data-name="GitHub" style="--color: #333">💻</a>
a::before { content: attr(data-name); }
§5 CSS Patterns
CSS Custom Properties / Design Tokens
:root {
--color-primary: oklch(54% 0.23 255);
--color-bg: #ffffff;
--color-text: #1a1a1a;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 2rem;
--radius: 0.5rem;
--font-body: system-ui, -apple-system, sans-serif;
}
.btn { background: var(--color-primary, blue); }
.card { --radius: 1rem; }
OKLCH Color System
:root {
--lch-blue: 54% 0.23 255;
--lch-blue-light: 95% 0.03 255;
--lch-blue-dark: 80% 0.08 255;
--color-link: oklch(var(--lch-blue));
--color-selected: oklch(var(--lch-blue-light));
--color-selected-dark: oklch(var(--lch-blue-dark));
--color-link-50: oklch(var(--lch-blue) / 0.5);
}
Relative Colors from Syntax
:root {
--color-primary: oklch(54% 0.23 255);
}
.btn {
--btn-hover: oklch(from var(--color-primary) calc(l - 10%) c h);
--btn-active: oklch(from var(--color-primary) calc(l - 20%) c h);
background: var(--color-primary);
&:hover { background: var(--btn-hover); }
&:active { background: var(--btn-active); }
}
currentColor
.icon {
color: var(--color-primary);
filter: drop-shadow(0 0 4px currentColor);
}
.btn {
color: var(--color-primary);
border-color: currentColor;
background: oklch(from currentColor l c h / 0.1);
}
CSS Position Reference
.static { position: static; }
.relative { position: relative; top: 10px; left: 20px; }
.absolute { position: absolute; top: 0; right: 0; }
.fixed { position: fixed; bottom: 20px; right: 20px; }
.sticky { position: sticky; top: 0; }
.layer { position: relative; z-index: 10; }
CSS Nesting (no Sass needed)
.card {
background: var(--color-bg);
border-radius: var(--radius);
& h2 { font-size: 1.5rem; }
& p { color: var(--color-text); }
&:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
}
@layer for Specificity Management
@layer tokens, base, components, utilities;
@layer tokens {
:root { --color-primary: blue; }
}
@layer components {
.btn { background: var(--color-primary); }
}
@layer utilities {
.hidden { display: none; }
}
- Use when you have 3+ style sources (tokens, base, components, utilities) that need specificity ordering
Fluid Typography with clamp()
h1 { font-size: clamp(1.5rem, 4vw + 1rem, 3rem); }
body { font-size: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); }
text-wrap: balance
h1, h2, h3 { text-wrap: balance; }
Responsive Grid — No Media Queries
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
:has() Parent Selector
.card:has(.badge) { border: 2px solid var(--color-primary); }
nav:has(.active) { background: var(--color-bg); }
:not() Pseudo-class
li:not(:last-child) { border-bottom: 1px solid #eee; }
input:not([type="submit"]) { border: 1px solid #ccc; }
Glassmorphism
.glass {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
Neumorphism
.neumorphic {
background: #e0e0e0;
border-radius: 12px;
box-shadow: 8px 8px 15px #bebebe, -8px -8px 15px #ffffff;
}
.neumorphic:active {
box-shadow: inset 4px 4px 8px #bebebe, inset -4px -4px 8px #ffffff;
}
Gradient Text
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
Scroll Snapping Carousel
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 1rem;
}
.carousel-card {
flex: 0 0 100%;
scroll-snap-align: start;
}
@media (max-width: 600px) {
.carousel-card { flex: 0 0 100%; }
}
::scroll-button() (CSS-Only Carousel Navigation)
.carousel {
scroll-snap-type: x mandatory;
}
.carousel::scroll-button(inline-start) { content: "←"; }
.carousel::scroll-button(inline-end) { content: "→"; }
.carousel::scroll-marker-group {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.carousel::scroll-marker { content: ""; width: 10px; height: 10px; border-radius: 50%; }
.carousel::scroll-marker:target-current { background: var(--color-primary); }
- Chrome 135+, no Firefox/Safari support yet
CSS-Only Dropdown with :checked
.dropdown { display: none; }
.dropdown-toggle:checked + .dropdown-label + .dropdown { display: block; }
.dropdown-wrapper:focus-within .dropdown { display: block; }
Popover API Styling
[popover] {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
Anchor Positioning
.trigger { anchor-name: --my-trigger; }
.tooltip {
position: fixed;
position-anchor: --my-trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 8px;
}
@property for Animating Custom Properties
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.animated-border {
--border-color: conic-gradient(from var(--angle), #667eea, #764ba2, #667eea);
border: 2px solid transparent;
animation: rotate-border 3s linear infinite;
}
@keyframes rotate-border {
to { --angle: 360deg; }
}
Border Animations with Conic Gradients
.animated-border {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.animated-border::before {
content: '';
position: absolute;
inset: -2px;
background: conic-gradient(from var(--angle), #667eea, #764ba2, #667eea);
border-radius: inherit;
z-index: -1;
animation: rotate-border 3s linear infinite;
}
Flexbox Properties Reference
.container {
display: flex;
flex-direction: row | row-reverse | column | column-reverse;
justify-content: flex-start | flex-end | center | space-between | space-around | space-evenly;
align-items: flex-start | flex-end | center | stretch | baseline;
flex-wrap: nowrap | wrap | wrap-reverse;
align-content: flex-start | flex-end | center | space-between | space-around;
gap: 1rem;
}
.item {
flex: 1 1 auto;
flex: 0 0 200px;
flex: 1;
align-self: flex-start | flex-end | center | stretch;
order: -1 | 0 | 1;
}
When to use Grid vs Flexbox: Grid for 2D layouts (rows AND columns). Flexbox for 1D layouts (row OR column). Grid: parent defines layout, children follow. Flexbox: children determine layout.
display: inline-flex
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #eee;
border-radius: 4px;
}
Flexbox Patterns
nav ul { display: flex; gap: 1rem; }
nav ul li:first-child { margin-right: auto; }
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
.equal-cols { display: flex; gap: 1rem; }
.equal-cols > * { flex: 1; }
.clickable { display: flex; align-items: center; }
.clickable a { flex-grow: 1; }
.card { display: flex; flex-direction: column; }
.card-content { flex: 1; }
.card button { margin-top: auto; }
Grid Patterns
.dashboard {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 250px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.footer { grid-area: footer; }
.stack { display: grid; }
.stack > * { grid-area: 1 / 1; }
Resize Property
textarea { resize: both; }
textarea { resize: vertical; }
textarea { resize: none; }
Cubic Bezier Easing
.btn { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.btn { transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.btn { transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); }
Border-Image for Gradient Borders
.gradient-border {
border: 3px solid;
border-image: linear-gradient(135deg, #667eea, #764ba2) 1;
}
Animation & Transition Reference
.element {
animation: name duration timing-function delay iteration-count direction fill-mode;
animation: fade-in 0.3s ease 0s 1 normal forwards;
}
animation-name: fade-in;
animation-duration: 0.3s;
animation-timing-function: ease | linear | ease-in | ease-out | cubic-bezier(x, y, z, w);
animation-delay: 0s;
animation-iteration-count: 1 | infinite;
animation-direction: normal | reverse | alternate | alternate-reverse;
animation-fill-mode: none | forwards | backwards | both;
.element {
transition: property duration timing-function delay;
transition: all 0.3s ease 0s;
transition: background 0.2s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
transition-property: all | background | transform | opacity;
transition-duration: 0.3s;
transition-timing-function: ease;
transition-delay: 0s;
@starting-style for Entry Animations
dialog {
opacity: 1;
transform: scale(1);
transition: opacity 0.3s, transform 0.3s, display 0.3s allow-discrete;
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}
light-dark() for Theme Switching
:root { color-scheme: light dark; }
body {
background: light-dark(#ffffff, #121212);
color: light-dark(#1a1a1a, #e0e0e0);
}
aspect-ratio
.video { aspect-ratio: 16 / 9; }
.square { aspect-ratio: 1; }
.portrait { aspect-ratio: 3 / 4; }
.card img { aspect-ratio: 4 / 3; width: 100%; object-fit: cover; }
min() and max() Functions
.container { width: min(800px, 90%); }
.sidebar { width: max(250px, 25%); }
.section { padding: min(5rem, 8vw); }
Columns (Masonry Layout)
.gallery {
columns: 3;
column-gap: 1rem;
}
.gallery-item {
break-inside: avoid;
margin-bottom: 1rem;
}
@media (max-width: 900px) { .gallery { columns: 2; } }
@media (max-width: 600px) { .gallery { columns: 1; } }
Use light-dark() for auto-only theme (respects OS). Use data-theme attribute toggle when user can override.
Container Queries
.card-container { container-type: inline-size; }
@container (min-width: 400px) {
.card { display: grid; grid-template-columns: 1fr 2fr; }
}
CSS Shorthand vs Longhand
margin: 1rem 2rem;
background: #fff url('img.png') no-repeat center / cover;
margin-top: 1rem;
CSS @function (Custom Functions)
@function --transparent(--color, --alpha: 0.2) {
result: oklch(from var(--color) l c h / var(--alpha));
}
@function --inner-radius(--outer, --padding) {
result: max(0px, var(--outer) - var(--padding));
}
.card {
background: --transparent(var(--color-primary), 0.1);
border: 1px solid --transparent(var(--color-primary), 0.2);
}
- Parameters start with
-- like custom properties
result defines the return value (not return like JS)
- Last
result wins (CSS cascade behavior)
- Can include media queries inside functions for responsive logic
- Browser support: limited (Chrome 133+, not production-ready yet)
CSS Subgrid
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto 1fr auto;
gap: 1rem;
}
.card {
grid-row: span 3;
grid-template-rows: subgrid;
}
.card { gap: 0; }
- Children inherit parent grid tracks instead of creating their own
- Solves the "misaligned buttons in cards" problem
- Use
span N instead of hardcoded line numbers for responsive grids
- Gap inheritance: parent gap applies to subgrid items too
View Transitions API
::view-transition-old(root) {
animation: fade-out 0.3s ease;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease;
}
.hero-title {
view-transition-name: hero;
}
document.startViewTransition(() => {
updateDOM();
});
- Animates between page states without a full page reload
- Works with SPA navigation and MPA with
@view-transition
- Chrome 111+, Firefox behind flag, Safari 18+
sibling-index() and sibling-count()
Warning: No widespread browser support yet. Use CSS counters or JavaScript for equivalent functionality in production.
li {
opacity: calc(1 - (sibling-index() - 1) * 0.1);
}
li::before {
content: sibling-index();
}
.item {
width: calc(100% / sibling-count());
}
sibling-index() — 1-based position among siblings
sibling-count() — total number of siblings
- Replaces manual counter/JS solutions for nth-child calculations
corner-shape
.card {
border-radius: 1rem;
corner-shape: round;
}
.card {
border-radius: 1rem;
corner-shape: superellipse;
}
- New CSS property for controlling corner curvature
round (default), superellipse, bevel
- Creates iOS-style squircle corners natively
- Chrome 137+, experimental
Position Fallbacks (@position-try)
@position-try --below-right {
inset: unset;
top: anchor(bottom);
left: anchor(left);
}
@position-try --below-left {
inset: unset;
top: anchor(bottom);
right: anchor(right);
}
.menu {
position: absolute;
position-anchor: --my-button;
position-area: bottom right;
position-try-fallbacks: --below-right, --below-left;
}
- CSS-only intelligent positioning without JavaScript
- Browser tries each fallback until one fits in viewport
- Works with anchor positioning for dropdowns, tooltips, popovers
- Chrome 129+, limited Firefox/Safari support
Intersection Observer (Scroll Animations)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
} else {
entry.target.classList.remove('visible');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.animate').forEach(el => observer.observe(el));
.animate {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s, transform 0.5s;
}
.animate.visible {
opacity: 1;
transform: translateY(0);
}
- Detects when elements enter/leave viewport
- Better than scroll events (no performance issues)
- Use for: scroll animations, lazy loading, infinite scroll
Scroll-Driven Animations (CSS-only)
@keyframes fade-in {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
.progress {
animation: grow-width linear;
animation-timeline: scroll();
}
@keyframes grow-width {
from { width: 0%; }
to { width: 100%; }
}
animation-timeline: view() — tied to element visibility
animation-timeline: scroll() — tied to page scroll
- No JavaScript needed for scroll-triggered animations
color-mix()
.btn-hover { background: color-mix(in oklch, var(--primary), black 15%); }
.subtle { background: color-mix(in oklch, var(--bg), var(--text) 5%); }
.border { border-color: color-mix(in oklch, var(--primary), transparent 50%); }
@scope (Scoped Styling)
@scope (.card) to (.card-footer) {
p { color: var(--text); }
a { color: var(--primary); }
}
- Prevents style leakage to siblings or parents
- The
to clause limits scope to a boundary element
- Chrome 118+, Firefox 128+, Safari 17.4+ (partial)