20 / 20
All 20 in 20 CSS Page Transitions
- 01Page Transition Overlay Cover Swap Reveal
- 02Exit and Enter Timing Without Overlap
- 03Holding Scroll Position Across a Route Swap
- 04Route Transitions Between Different Page Heights
- 05FLIP Element Continuity Without the API
- 06Preloading Routes So Transitions Never Stall
- 07Pending State When the Next Route Is Slow
- 08Back and Forward Navigation Transition Behavior
- 09Accessible Route Change Focus and Announcements
- 10CSS Page Fade Transition on Route Change
- 11Slide Page Transition Left and Right
- 12Zoom Page Transition Scale In and Out
- 13Clip Path Wipe Page Transition Effect
- 14Blur Page Transition Between Routes
- 15Book Page Turn Transition Effect CSS
- 16Ink Bleed Mask Page Transition
- 17Shatter Page Transition with Clip Path Tiles
- 18Split Curtain Page Transition Effect
- 19Terminal Text Page Transition Overlay
- 20Brush Stroke Page Transition Mask
Brush Stroke Page Transition Mask
An SVG mask carries a thick, tapered brush path; animating its stroke-dashoffset paints the mask on, which sweeps the overlay across the viewport as a single stroke and then lifts it the same way. Higher fidelity than the radial bleed in demo 16, and the place where namespacing matters most — SVG mask ids are document-global, so two demos both defining one means the second silently wins.
Build interactive UI like this from scratch: 20 HTML, CSS & JavaScript projects
ZTM · JavaScript Web Projects · 30‑day money-back guarantee · Affiliate link
The code
<section class="pt-20" aria-labelledby="pt-20-h">
<div class="pt-20__theme">
<input class="pt-20__theme-input" type="checkbox" id="pt-20-theme">
<label class="pt-20__theme-label" for="pt-20-theme">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="pt-20__sr">Toggle dark theme</span>
</label>
</div>
<div class="pt-20__stage">
<header class="pt-20__head">
<p class="pt-20__kicker">Page transitions / 20</p>
<h2 class="pt-20__h" id="pt-20-h">One stroke, painted and lifted</h2>
<p class="pt-20__lede">Rooms are DOM sections swapped by this demo’s script at full coverage. The mask id is namespaced <code>pt-20-brush-mask</code> because SVG ids are shared across the whole document.</p>
</header>
<div class="pt-20__frame">
<div class="pt-20__view">
<svg class="pt-20__brush" viewBox="0 0 800 400" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<mask id="pt-20-brush-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="800" height="400">
<path class="pt-20__stroke" d="M-120 150 C 140 40, 300 330, 520 190 S 760 60, 940 210" pathLength="1" fill="none" stroke="#fff" stroke-width="420" stroke-linecap="round"/>
</mask>
</defs>
<rect class="pt-20__paint" x="0" y="0" width="800" height="400" mask="url(#pt-20-brush-mask)"/>
</svg>
<main class="pt-20__room" id="pt-20-room" tabindex="-1">
<p class="pt-20__eyebrow">Room 1 · Ground floor</p>
<h3 class="pt-20__title">Marks and Weather</h3>
<p class="pt-20__text">Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.</p>
<dl class="pt-20__facts">
<div><dt>Works</dt><dd>22</dd></div>
<div><dt>Medium</dt><dd>Ink, gouache</dd></div>
<div><dt>Until</dt><dd>30 Nov</dd></div>
</dl>
</main>
</div>
<nav class="pt-20__rooms" aria-label="Rooms">
<button class="pt-20__room-btn pt-20__room-btn--current" type="button" data-pt-20-room="one">Room 1</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="two">Room 2</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="three">Room 3</button>
</nav>
</div>
</div>
</section><section class="pt-20" aria-labelledby="pt-20-h">
<div class="pt-20__theme">
<input class="pt-20__theme-input" type="checkbox" id="pt-20-theme">
<label class="pt-20__theme-label" for="pt-20-theme">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="pt-20__sr">Toggle dark theme</span>
</label>
</div>
<div class="pt-20__stage">
<header class="pt-20__head">
<p class="pt-20__kicker">Page transitions / 20</p>
<h2 class="pt-20__h" id="pt-20-h">One stroke, painted and lifted</h2>
<p class="pt-20__lede">Rooms are DOM sections swapped by this demo’s script at full coverage. The mask id is namespaced <code>pt-20-brush-mask</code> because SVG ids are shared across the whole document.</p>
</header>
<div class="pt-20__frame">
<div class="pt-20__view">
<svg class="pt-20__brush" viewBox="0 0 800 400" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<mask id="pt-20-brush-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="800" height="400">
<path class="pt-20__stroke" d="M-120 150 C 140 40, 300 330, 520 190 S 760 60, 940 210" pathLength="1" fill="none" stroke="#fff" stroke-width="420" stroke-linecap="round"/>
</mask>
</defs>
<rect class="pt-20__paint" x="0" y="0" width="800" height="400" mask="url(#pt-20-brush-mask)"/>
</svg>
<main class="pt-20__room" id="pt-20-room" tabindex="-1">
<p class="pt-20__eyebrow">Room 1 · Ground floor</p>
<h3 class="pt-20__title">Marks and Weather</h3>
<p class="pt-20__text">Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.</p>
<dl class="pt-20__facts">
<div><dt>Works</dt><dd>22</dd></div>
<div><dt>Medium</dt><dd>Ink, gouache</dd></div>
<div><dt>Until</dt><dd>30 Nov</dd></div>
</dl>
</main>
</div>
<nav class="pt-20__rooms" aria-label="Rooms">
<button class="pt-20__room-btn pt-20__room-btn--current" type="button" data-pt-20-room="one">Room 1</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="two">Room 2</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="three">Room 3</button>
</nav>
</div>
</div>
</section>.pt-20 {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
--pt-20-font-display: "Rockwell", "Roboto Slab", "Bookman Old Style", Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
background: var(--pt-20-bg);
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
background-size: 3px 3px;
color: var(--pt-20-ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.6;
}
.pt-20 *,
.pt-20 *::before,
.pt-20 *::after {
box-sizing: border-box;
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
}
}
@media (prefers-color-scheme: light) {
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
}
@supports (color: oklch(0.55 0.17 35)) {
.pt-20 {
--pt-20-acc: oklch(0.55 0.17 36);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.73 0.14 42);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-acc: oklch(0.73 0.14 42);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.55 0.17 36);
}
}
}
.pt-20__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pt-20__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
}
.pt-20__theme-input {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
margin: 0;
}
.pt-20__theme-label {
display: grid;
place-items: center;
inline-size: 2.75rem;
block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-acc);
cursor: pointer;
}
.pt-20__theme-label svg {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.pt-20__theme-input:focus-visible + .pt-20__theme-label {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
.pt-20__stage {
max-inline-size: 62rem;
margin-inline: auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.25rem) 3rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
.pt-20__kicker {
margin: 0 0 0.45rem;
font-family: var(--pt-20-font-display);
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--pt-20-acc);
}
.pt-20__h {
margin: 0 0 0.5rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.5rem, 4.8vw, 2.3rem);
font-weight: 700;
letter-spacing: -0.015em;
}
.pt-20__lede {
margin: 0;
max-inline-size: 42rem;
color: var(--pt-20-mut);
font-size: 0.94rem;
text-wrap: pretty;
}
.pt-20__lede code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.86em;
}
.pt-20__frame {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.pt-20__view {
position: relative;
overflow: hidden;
min-inline-size: 0;
min-block-size: 17rem;
background: var(--pt-20-s1);
border: 1px solid var(--pt-20-line);
}
.pt-20__brush {
position: absolute;
inset: 0;
z-index: 3;
inline-size: 100%;
block-size: 100%;
pointer-events: none;
}
.pt-20__paint {
fill: var(--pt-20-acc);
}
.pt-20__stroke {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke {
animation: pt-20-paint 420ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: pt-20-lift 460ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
@keyframes pt-20-paint {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes pt-20-lift {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -1;
}
}
.pt-20__room {
display: block;
padding: clamp(1.1rem, 3.5vw, 2rem);
}
.pt-20__room:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: -4px;
}
.pt-20__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__title {
margin: 0 0 0.7rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.35rem, 4.4vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.pt-20__text {
margin: 0 0 1.1rem;
max-inline-size: 34rem;
font-size: 0.95rem;
color: var(--pt-20-mut);
text-wrap: pretty;
}
.pt-20__facts {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.6rem 2rem;
}
.pt-20__facts div {
min-inline-size: 0;
}
.pt-20__facts dt {
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__facts dd {
margin: 0.1rem 0 0;
font-family: var(--pt-20-font-display);
font-size: 1rem;
font-weight: 700;
}
.pt-20__rooms {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pt-20__room-btn {
flex: 1 1 7rem;
min-inline-size: 0;
min-block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-mut);
font-family: var(--pt-20-font-display);
font-size: 0.88rem;
cursor: pointer;
}
.pt-20__room-btn--current {
background: var(--pt-20-ink);
border-color: var(--pt-20-ink);
color: var(--pt-20-bg);
}
.pt-20__room-btn:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke,
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: none;
stroke-dashoffset: 1;
}
.pt-20__room {
transition: opacity 90ms linear;
}
}.pt-20 {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
--pt-20-font-display: "Rockwell", "Roboto Slab", "Bookman Old Style", Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
background: var(--pt-20-bg);
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
background-size: 3px 3px;
color: var(--pt-20-ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.6;
}
.pt-20 *,
.pt-20 *::before,
.pt-20 *::after {
box-sizing: border-box;
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
}
}
@media (prefers-color-scheme: light) {
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
}
@supports (color: oklch(0.55 0.17 35)) {
.pt-20 {
--pt-20-acc: oklch(0.55 0.17 36);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.73 0.14 42);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-acc: oklch(0.73 0.14 42);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.55 0.17 36);
}
}
}
.pt-20__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pt-20__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
}
.pt-20__theme-input {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
margin: 0;
}
.pt-20__theme-label {
display: grid;
place-items: center;
inline-size: 2.75rem;
block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-acc);
cursor: pointer;
}
.pt-20__theme-label svg {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.pt-20__theme-input:focus-visible + .pt-20__theme-label {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
.pt-20__stage {
max-inline-size: 62rem;
margin-inline: auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.25rem) 3rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
.pt-20__kicker {
margin: 0 0 0.45rem;
font-family: var(--pt-20-font-display);
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--pt-20-acc);
}
.pt-20__h {
margin: 0 0 0.5rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.5rem, 4.8vw, 2.3rem);
font-weight: 700;
letter-spacing: -0.015em;
}
.pt-20__lede {
margin: 0;
max-inline-size: 42rem;
color: var(--pt-20-mut);
font-size: 0.94rem;
text-wrap: pretty;
}
.pt-20__lede code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.86em;
}
.pt-20__frame {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.pt-20__view {
position: relative;
overflow: hidden;
min-inline-size: 0;
min-block-size: 17rem;
background: var(--pt-20-s1);
border: 1px solid var(--pt-20-line);
}
.pt-20__brush {
position: absolute;
inset: 0;
z-index: 3;
inline-size: 100%;
block-size: 100%;
pointer-events: none;
}
.pt-20__paint {
fill: var(--pt-20-acc);
}
.pt-20__stroke {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke {
animation: pt-20-paint 420ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: pt-20-lift 460ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
@keyframes pt-20-paint {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes pt-20-lift {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -1;
}
}
.pt-20__room {
display: block;
padding: clamp(1.1rem, 3.5vw, 2rem);
}
.pt-20__room:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: -4px;
}
.pt-20__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__title {
margin: 0 0 0.7rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.35rem, 4.4vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.pt-20__text {
margin: 0 0 1.1rem;
max-inline-size: 34rem;
font-size: 0.95rem;
color: var(--pt-20-mut);
text-wrap: pretty;
}
.pt-20__facts {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.6rem 2rem;
}
.pt-20__facts div {
min-inline-size: 0;
}
.pt-20__facts dt {
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__facts dd {
margin: 0.1rem 0 0;
font-family: var(--pt-20-font-display);
font-size: 1rem;
font-weight: 700;
}
.pt-20__rooms {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pt-20__room-btn {
flex: 1 1 7rem;
min-inline-size: 0;
min-block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-mut);
font-family: var(--pt-20-font-display);
font-size: 0.88rem;
cursor: pointer;
}
.pt-20__room-btn--current {
background: var(--pt-20-ink);
border-color: var(--pt-20-ink);
color: var(--pt-20-bg);
}
.pt-20__room-btn:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke,
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: none;
stroke-dashoffset: 1;
}
.pt-20__room {
transition: opacity 90ms linear;
}
}const root = document.querySelector('.pt-20');
const view = root.querySelector('#pt-20-room');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const timers = []; let busy = false;
const rooms = {
one: ['Room 1 · Ground floor', 'Marks and Weather', 'Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.', [['Works', '22'], ['Medium', 'Ink, gouache'], ['Until', '30 Nov']]],
two: ['Room 2 · Ground floor', 'The Long Table', 'A single work, nine metres, shown flat for the first time since it was made.', [['Works', '1'], ['Medium', 'Oil on linen'], ['Until', '18 Jan']]],
three: ['Room 3 · First floor', 'Studies for a Wall', 'Preparatory drawings for the west wall, with the surviving cartoon and two rejected schemes.', [['Works', '31'], ['Medium', 'Chalk, wash'], ['Until', '14 Mar']]]
};
function go(key, btn) {
if (busy || btn.classList.contains('pt-20__room-btn--current')) return;
busy = true;
root.setAttribute('data-pt-20-phase', 'paint');
timers.push(setTimeout(() => {
const [eyebrow, title, text, facts] = rooms[key];
view.innerHTML = '<p class="pt-20__eyebrow">' + eyebrow + '</p><h3 class="pt-20__title">' + title + '</h3><p class="pt-20__text">' + text + '</p><dl class="pt-20__facts">' + facts.map((f) => '<div><dt>' + f[0] + '</dt><dd>' + f[1] + '</dd></div>').join('') + '</dl>';
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.classList.toggle('pt-20__room-btn--current', b === btn));
view.focus({ preventScroll: true });
root.setAttribute('data-pt-20-phase', 'lift');
timers.push(setTimeout(() => { root.setAttribute('data-pt-20-phase', 'idle'); busy = false; }, reduce.matches ? 40 : 470));
}, reduce.matches ? 40 : 430));
}
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.addEventListener('click', () => go(b.getAttribute('data-pt-20-room'), b)));
root.__ptCleanup = () => { timers.forEach(clearTimeout); root.setAttribute('data-pt-20-phase', 'idle'); };const root = document.querySelector('.pt-20');
const view = root.querySelector('#pt-20-room');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const timers = []; let busy = false;
const rooms = {
one: ['Room 1 · Ground floor', 'Marks and Weather', 'Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.', [['Works', '22'], ['Medium', 'Ink, gouache'], ['Until', '30 Nov']]],
two: ['Room 2 · Ground floor', 'The Long Table', 'A single work, nine metres, shown flat for the first time since it was made.', [['Works', '1'], ['Medium', 'Oil on linen'], ['Until', '18 Jan']]],
three: ['Room 3 · First floor', 'Studies for a Wall', 'Preparatory drawings for the west wall, with the surviving cartoon and two rejected schemes.', [['Works', '31'], ['Medium', 'Chalk, wash'], ['Until', '14 Mar']]]
};
function go(key, btn) {
if (busy || btn.classList.contains('pt-20__room-btn--current')) return;
busy = true;
root.setAttribute('data-pt-20-phase', 'paint');
timers.push(setTimeout(() => {
const [eyebrow, title, text, facts] = rooms[key];
view.innerHTML = '<p class="pt-20__eyebrow">' + eyebrow + '</p><h3 class="pt-20__title">' + title + '</h3><p class="pt-20__text">' + text + '</p><dl class="pt-20__facts">' + facts.map((f) => '<div><dt>' + f[0] + '</dt><dd>' + f[1] + '</dd></div>').join('') + '</dl>';
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.classList.toggle('pt-20__room-btn--current', b === btn));
view.focus({ preventScroll: true });
root.setAttribute('data-pt-20-phase', 'lift');
timers.push(setTimeout(() => { root.setAttribute('data-pt-20-phase', 'idle'); busy = false; }, reduce.matches ? 40 : 470));
}, reduce.matches ? 40 : 430));
}
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.addEventListener('click', () => go(b.getAttribute('data-pt-20-room'), b)));
root.__ptCleanup = () => { timers.forEach(clearTimeout); root.setAttribute('data-pt-20-phase', 'idle'); };Here's a working CSS Page Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Brush Stroke Page Transition Mask
Source: https://codefronts.com/motion/css-page-transitions/paint-brush-stroke/
An SVG mask carries a thick, tapered brush path; animating its stroke-dashoffset paints the mask on, which sweeps the overlay across the viewport as a single stroke and then lifts it the same way. Higher fidelity than the radial bleed in demo 16, and the place where namespacing matters most — SVG mask ids are document-global, so two demos both defining one means the second silently wins.
## HTML
```html
<section class="pt-20" aria-labelledby="pt-20-h">
<div class="pt-20__theme">
<input class="pt-20__theme-input" type="checkbox" id="pt-20-theme">
<label class="pt-20__theme-label" for="pt-20-theme">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="pt-20__sr">Toggle dark theme</span>
</label>
</div>
<div class="pt-20__stage">
<header class="pt-20__head">
<p class="pt-20__kicker">Page transitions / 20</p>
<h2 class="pt-20__h" id="pt-20-h">One stroke, painted and lifted</h2>
<p class="pt-20__lede">Rooms are DOM sections swapped by this demo’s script at full coverage. The mask id is namespaced <code>pt-20-brush-mask</code> because SVG ids are shared across the whole document.</p>
</header>
<div class="pt-20__frame">
<div class="pt-20__view">
<svg class="pt-20__brush" viewBox="0 0 800 400" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<mask id="pt-20-brush-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="800" height="400">
<path class="pt-20__stroke" d="M-120 150 C 140 40, 300 330, 520 190 S 760 60, 940 210" pathLength="1" fill="none" stroke="#fff" stroke-width="420" stroke-linecap="round"/>
</mask>
</defs>
<rect class="pt-20__paint" x="0" y="0" width="800" height="400" mask="url(#pt-20-brush-mask)"/>
</svg>
<main class="pt-20__room" id="pt-20-room" tabindex="-1">
<p class="pt-20__eyebrow">Room 1 · Ground floor</p>
<h3 class="pt-20__title">Marks and Weather</h3>
<p class="pt-20__text">Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.</p>
<dl class="pt-20__facts">
<div><dt>Works</dt><dd>22</dd></div>
<div><dt>Medium</dt><dd>Ink, gouache</dd></div>
<div><dt>Until</dt><dd>30 Nov</dd></div>
</dl>
</main>
</div>
<nav class="pt-20__rooms" aria-label="Rooms">
<button class="pt-20__room-btn pt-20__room-btn--current" type="button" data-pt-20-room="one">Room 1</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="two">Room 2</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="three">Room 3</button>
</nav>
</div>
</div>
</section>
```
## CSS
```css
.pt-20 {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
--pt-20-font-display: "Rockwell", "Roboto Slab", "Bookman Old Style", Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
background: var(--pt-20-bg);
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
background-size: 3px 3px;
color: var(--pt-20-ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.6;
}
.pt-20 *,
.pt-20 *::before,
.pt-20 *::after {
box-sizing: border-box;
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
}
}
@media (prefers-color-scheme: light) {
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
}
@supports (color: oklch(0.55 0.17 35)) {
.pt-20 {
--pt-20-acc: oklch(0.55 0.17 36);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.73 0.14 42);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-acc: oklch(0.73 0.14 42);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.55 0.17 36);
}
}
}
.pt-20__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pt-20__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
}
.pt-20__theme-input {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
margin: 0;
}
.pt-20__theme-label {
display: grid;
place-items: center;
inline-size: 2.75rem;
block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-acc);
cursor: pointer;
}
.pt-20__theme-label svg {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.pt-20__theme-input:focus-visible + .pt-20__theme-label {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
.pt-20__stage {
max-inline-size: 62rem;
margin-inline: auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.25rem) 3rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
.pt-20__kicker {
margin: 0 0 0.45rem;
font-family: var(--pt-20-font-display);
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--pt-20-acc);
}
.pt-20__h {
margin: 0 0 0.5rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.5rem, 4.8vw, 2.3rem);
font-weight: 700;
letter-spacing: -0.015em;
}
.pt-20__lede {
margin: 0;
max-inline-size: 42rem;
color: var(--pt-20-mut);
font-size: 0.94rem;
text-wrap: pretty;
}
.pt-20__lede code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.86em;
}
.pt-20__frame {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.pt-20__view {
position: relative;
overflow: hidden;
min-inline-size: 0;
min-block-size: 17rem;
background: var(--pt-20-s1);
border: 1px solid var(--pt-20-line);
}
.pt-20__brush {
position: absolute;
inset: 0;
z-index: 3;
inline-size: 100%;
block-size: 100%;
pointer-events: none;
}
.pt-20__paint {
fill: var(--pt-20-acc);
}
.pt-20__stroke {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke {
animation: pt-20-paint 420ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: pt-20-lift 460ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
@keyframes pt-20-paint {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes pt-20-lift {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -1;
}
}
.pt-20__room {
display: block;
padding: clamp(1.1rem, 3.5vw, 2rem);
}
.pt-20__room:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: -4px;
}
.pt-20__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__title {
margin: 0 0 0.7rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.35rem, 4.4vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.pt-20__text {
margin: 0 0 1.1rem;
max-inline-size: 34rem;
font-size: 0.95rem;
color: var(--pt-20-mut);
text-wrap: pretty;
}
.pt-20__facts {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.6rem 2rem;
}
.pt-20__facts div {
min-inline-size: 0;
}
.pt-20__facts dt {
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__facts dd {
margin: 0.1rem 0 0;
font-family: var(--pt-20-font-display);
font-size: 1rem;
font-weight: 700;
}
.pt-20__rooms {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pt-20__room-btn {
flex: 1 1 7rem;
min-inline-size: 0;
min-block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-mut);
font-family: var(--pt-20-font-display);
font-size: 0.88rem;
cursor: pointer;
}
.pt-20__room-btn--current {
background: var(--pt-20-ink);
border-color: var(--pt-20-ink);
color: var(--pt-20-bg);
}
.pt-20__room-btn:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke,
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: none;
stroke-dashoffset: 1;
}
.pt-20__room {
transition: opacity 90ms linear;
}
}
```
## JavaScript
```js
const root = document.querySelector('.pt-20');
const view = root.querySelector('#pt-20-room');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const timers = []; let busy = false;
const rooms = {
one: ['Room 1 · Ground floor', 'Marks and Weather', 'Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.', [['Works', '22'], ['Medium', 'Ink, gouache'], ['Until', '30 Nov']]],
two: ['Room 2 · Ground floor', 'The Long Table', 'A single work, nine metres, shown flat for the first time since it was made.', [['Works', '1'], ['Medium', 'Oil on linen'], ['Until', '18 Jan']]],
three: ['Room 3 · First floor', 'Studies for a Wall', 'Preparatory drawings for the west wall, with the surviving cartoon and two rejected schemes.', [['Works', '31'], ['Medium', 'Chalk, wash'], ['Until', '14 Mar']]]
};
function go(key, btn) {
if (busy || btn.classList.contains('pt-20__room-btn--current')) return;
busy = true;
root.setAttribute('data-pt-20-phase', 'paint');
timers.push(setTimeout(() => {
const [eyebrow, title, text, facts] = rooms[key];
view.innerHTML = '<p class="pt-20__eyebrow">' + eyebrow + '</p><h3 class="pt-20__title">' + title + '</h3><p class="pt-20__text">' + text + '</p><dl class="pt-20__facts">' + facts.map((f) => '<div><dt>' + f[0] + '</dt><dd>' + f[1] + '</dd></div>').join('') + '</dl>';
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.classList.toggle('pt-20__room-btn--current', b === btn));
view.focus({ preventScroll: true });
root.setAttribute('data-pt-20-phase', 'lift');
timers.push(setTimeout(() => { root.setAttribute('data-pt-20-phase', 'idle'); busy = false; }, reduce.matches ? 40 : 470));
}, reduce.matches ? 40 : 430));
}
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.addEventListener('click', () => go(b.getAttribute('data-pt-20-room'), b)));
root.__ptCleanup = () => { timers.forEach(clearTimeout); root.setAttribute('data-pt-20-phase', 'idle'); };
```Here's a working CSS Page Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Brush Stroke Page Transition Mask
Source: https://codefronts.com/motion/css-page-transitions/paint-brush-stroke/
An SVG mask carries a thick, tapered brush path; animating its stroke-dashoffset paints the mask on, which sweeps the overlay across the viewport as a single stroke and then lifts it the same way. Higher fidelity than the radial bleed in demo 16, and the place where namespacing matters most — SVG mask ids are document-global, so two demos both defining one means the second silently wins.
## HTML
```html
<section class="pt-20" aria-labelledby="pt-20-h">
<div class="pt-20__theme">
<input class="pt-20__theme-input" type="checkbox" id="pt-20-theme">
<label class="pt-20__theme-label" for="pt-20-theme">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="pt-20__sr">Toggle dark theme</span>
</label>
</div>
<div class="pt-20__stage">
<header class="pt-20__head">
<p class="pt-20__kicker">Page transitions / 20</p>
<h2 class="pt-20__h" id="pt-20-h">One stroke, painted and lifted</h2>
<p class="pt-20__lede">Rooms are DOM sections swapped by this demo’s script at full coverage. The mask id is namespaced <code>pt-20-brush-mask</code> because SVG ids are shared across the whole document.</p>
</header>
<div class="pt-20__frame">
<div class="pt-20__view">
<svg class="pt-20__brush" viewBox="0 0 800 400" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<mask id="pt-20-brush-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="800" height="400">
<path class="pt-20__stroke" d="M-120 150 C 140 40, 300 330, 520 190 S 760 60, 940 210" pathLength="1" fill="none" stroke="#fff" stroke-width="420" stroke-linecap="round"/>
</mask>
</defs>
<rect class="pt-20__paint" x="0" y="0" width="800" height="400" mask="url(#pt-20-brush-mask)"/>
</svg>
<main class="pt-20__room" id="pt-20-room" tabindex="-1">
<p class="pt-20__eyebrow">Room 1 · Ground floor</p>
<h3 class="pt-20__title">Marks and Weather</h3>
<p class="pt-20__text">Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.</p>
<dl class="pt-20__facts">
<div><dt>Works</dt><dd>22</dd></div>
<div><dt>Medium</dt><dd>Ink, gouache</dd></div>
<div><dt>Until</dt><dd>30 Nov</dd></div>
</dl>
</main>
</div>
<nav class="pt-20__rooms" aria-label="Rooms">
<button class="pt-20__room-btn pt-20__room-btn--current" type="button" data-pt-20-room="one">Room 1</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="two">Room 2</button>
<button class="pt-20__room-btn" type="button" data-pt-20-room="three">Room 3</button>
</nav>
</div>
</div>
</section>
```
## CSS
```css
.pt-20 {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
--pt-20-font-display: "Rockwell", "Roboto Slab", "Bookman Old Style", Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
background: var(--pt-20-bg);
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
background-size: 3px 3px;
color: var(--pt-20-ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.6;
}
.pt-20 *,
.pt-20 *::before,
.pt-20 *::after {
box-sizing: border-box;
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #f4f1ea;
--pt-20-s1: #fdfbf6;
--pt-20-s2: #eae5da;
--pt-20-line: #d5cec0;
--pt-20-ink: #191713;
--pt-20-mut: #6a6255;
--pt-20-acc: #c2431f;
background-image: radial-gradient(rgba(25, 23, 19, 0.03) 1px, transparent 1px);
}
}
@media (prefers-color-scheme: light) {
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-bg: #141210;
--pt-20-s1: #1c1a16;
--pt-20-s2: #24211c;
--pt-20-line: #363128;
--pt-20-ink: #f3efe6;
--pt-20-mut: #a69d8d;
--pt-20-acc: #ef7a4f;
background-image: radial-gradient(rgba(243, 239, 230, 0.035) 1px, transparent 1px);
}
}
@supports (color: oklch(0.55 0.17 35)) {
.pt-20 {
--pt-20-acc: oklch(0.55 0.17 36);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.73 0.14 42);
}
@media (prefers-color-scheme: dark) {
.pt-20 {
--pt-20-acc: oklch(0.73 0.14 42);
}
.pt-20:has(.pt-20__theme-input:checked) {
--pt-20-acc: oklch(0.55 0.17 36);
}
}
}
.pt-20__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pt-20__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
}
.pt-20__theme-input {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
margin: 0;
}
.pt-20__theme-label {
display: grid;
place-items: center;
inline-size: 2.75rem;
block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-acc);
cursor: pointer;
}
.pt-20__theme-label svg {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.pt-20__theme-input:focus-visible + .pt-20__theme-label {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
.pt-20__stage {
max-inline-size: 62rem;
margin-inline: auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.25rem) 3rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
.pt-20__kicker {
margin: 0 0 0.45rem;
font-family: var(--pt-20-font-display);
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--pt-20-acc);
}
.pt-20__h {
margin: 0 0 0.5rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.5rem, 4.8vw, 2.3rem);
font-weight: 700;
letter-spacing: -0.015em;
}
.pt-20__lede {
margin: 0;
max-inline-size: 42rem;
color: var(--pt-20-mut);
font-size: 0.94rem;
text-wrap: pretty;
}
.pt-20__lede code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.86em;
}
.pt-20__frame {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.pt-20__view {
position: relative;
overflow: hidden;
min-inline-size: 0;
min-block-size: 17rem;
background: var(--pt-20-s1);
border: 1px solid var(--pt-20-line);
}
.pt-20__brush {
position: absolute;
inset: 0;
z-index: 3;
inline-size: 100%;
block-size: 100%;
pointer-events: none;
}
.pt-20__paint {
fill: var(--pt-20-acc);
}
.pt-20__stroke {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke {
animation: pt-20-paint 420ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: pt-20-lift 460ms cubic-bezier(0.5, 0, 0.4, 1) both;
}
@keyframes pt-20-paint {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes pt-20-lift {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -1;
}
}
.pt-20__room {
display: block;
padding: clamp(1.1rem, 3.5vw, 2rem);
}
.pt-20__room:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: -4px;
}
.pt-20__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__title {
margin: 0 0 0.7rem;
font-family: var(--pt-20-font-display);
font-size: clamp(1.35rem, 4.4vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.pt-20__text {
margin: 0 0 1.1rem;
max-inline-size: 34rem;
font-size: 0.95rem;
color: var(--pt-20-mut);
text-wrap: pretty;
}
.pt-20__facts {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.6rem 2rem;
}
.pt-20__facts div {
min-inline-size: 0;
}
.pt-20__facts dt {
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--pt-20-mut);
}
.pt-20__facts dd {
margin: 0.1rem 0 0;
font-family: var(--pt-20-font-display);
font-size: 1rem;
font-weight: 700;
}
.pt-20__rooms {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pt-20__room-btn {
flex: 1 1 7rem;
min-inline-size: 0;
min-block-size: 2.75rem;
border: 1px solid var(--pt-20-line);
border-radius: 0.3rem;
background: var(--pt-20-s1);
color: var(--pt-20-mut);
font-family: var(--pt-20-font-display);
font-size: 0.88rem;
cursor: pointer;
}
.pt-20__room-btn--current {
background: var(--pt-20-ink);
border-color: var(--pt-20-ink);
color: var(--pt-20-bg);
}
.pt-20__room-btn:focus-visible {
outline: 2px solid var(--pt-20-acc);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.pt-20[data-pt-20-phase="paint"] .pt-20__stroke,
.pt-20[data-pt-20-phase="lift"] .pt-20__stroke {
animation: none;
stroke-dashoffset: 1;
}
.pt-20__room {
transition: opacity 90ms linear;
}
}
```
## JavaScript
```js
const root = document.querySelector('.pt-20');
const view = root.querySelector('#pt-20-room');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const timers = []; let busy = false;
const rooms = {
one: ['Room 1 · Ground floor', 'Marks and Weather', 'Twenty-two works on paper made outdoors between 1962 and 1971. Hung in the order they were painted.', [['Works', '22'], ['Medium', 'Ink, gouache'], ['Until', '30 Nov']]],
two: ['Room 2 · Ground floor', 'The Long Table', 'A single work, nine metres, shown flat for the first time since it was made.', [['Works', '1'], ['Medium', 'Oil on linen'], ['Until', '18 Jan']]],
three: ['Room 3 · First floor', 'Studies for a Wall', 'Preparatory drawings for the west wall, with the surviving cartoon and two rejected schemes.', [['Works', '31'], ['Medium', 'Chalk, wash'], ['Until', '14 Mar']]]
};
function go(key, btn) {
if (busy || btn.classList.contains('pt-20__room-btn--current')) return;
busy = true;
root.setAttribute('data-pt-20-phase', 'paint');
timers.push(setTimeout(() => {
const [eyebrow, title, text, facts] = rooms[key];
view.innerHTML = '<p class="pt-20__eyebrow">' + eyebrow + '</p><h3 class="pt-20__title">' + title + '</h3><p class="pt-20__text">' + text + '</p><dl class="pt-20__facts">' + facts.map((f) => '<div><dt>' + f[0] + '</dt><dd>' + f[1] + '</dd></div>').join('') + '</dl>';
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.classList.toggle('pt-20__room-btn--current', b === btn));
view.focus({ preventScroll: true });
root.setAttribute('data-pt-20-phase', 'lift');
timers.push(setTimeout(() => { root.setAttribute('data-pt-20-phase', 'idle'); busy = false; }, reduce.matches ? 40 : 470));
}, reduce.matches ? 40 : 430));
}
root.querySelectorAll('[data-pt-20-room]').forEach((b) => b.addEventListener('click', () => go(b.getAttribute('data-pt-20-room'), b)));
root.__ptCleanup = () => { timers.forEach(clearTimeout); root.setAttribute('data-pt-20-phase', 'idle'); };
```How this works
The stroke is the mask, and the mask is animated by its own dash. A path with a very thick stroke, inside a mask, reveals whatever it covers. Setting pathLength="1" normalises the geometry so a dash array of 1 and an offset of 1 hides the stroke completely; animating the offset to 0 draws it across the frame. That single number is the whole animation.
The reveal is the same movement continued. Rather than reversing, the offset keeps going negative, which walks the dash off the far end — so the stroke appears to be wiped away in the same direction it was painted. That continuity is what makes it read as one gesture, the same principle as the curtain in demo 18 parting the way it did not close.
Namespacing is not a style preference here. mask, filter, clipPath and gradient ids live in one document-wide namespace. Two demos on one gallery page that both define #mask means every reference resolves to whichever appeared last, and the earlier demo's overlay silently renders unmasked — an opaque rectangle that never lifts. That is why every id here is prefixed pt-20-, along with the class names, keyframes and custom properties.
Cost is modest but not zero. A masked SVG rect is one composited layer and the dash animation is cheap, but a very wide stroke with round caps forces a large area to be rasterised each frame. Keep the brush path short in point count — three or four curves is plenty — and resist adding a filter to it, which is where this effect starts to cost as much as the blur in demo 14.
Make it yours
- Redraw the brush path to change the character entirely; a shallower curve reads as a roller, a steeper one as a signature.
- Change the stroke width; it needs to be at least the frame height to cover fully in one pass.
- Add a second, thinner stroke at a slight offset for a dry-brush edge.
- Reverse the sweep by starting the offset at -1 and animating to 0.
- Slow the lift relative to the paint; a stroke lifting slowly reads as more deliberate.
- Change the overlay fill to a gradient — the mask does not care what it is masking.
Gotchas — read before shipping
- SVG mask, filter and gradient ids are document-global. Two demos both defining
#maskmeans the second wins and the first renders unmasked. - Without
pathLength="1"the dash values depend on the path's real length, so any edit to the curve silently retimes the animation. - A stroke narrower than the frame height leaves uncovered bands at the top or bottom, and the swap becomes visible there.
- Adding a filter to a very wide stroke makes this as expensive as a full-viewport blur, which defeats the reason to choose a mask.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.
SVG masking and stroke-dashoffset animation are Baseline and long-supported (Chrome 4 / Safari 3.2 / Firefox 3) — unlike CSS mask-image in demo 16, an SVG mask applied inside the SVG needs no -webkit- prefix. The stated floor is oklch() for the vermilion plus the :has() theme toggle; below it the sRGB hex declared first applies and the theme follows the operating system.