19 / 20
All 20 in 20 CSS Scroll Animations
- 01Intersection Observer Fallback Contract
- 02Root Margin and Threshold Semantics
- 03Scroll Snap and Timeline Interaction
- 04Scroll Driven SVG Stroke Dashoffset
- 05Reduced Motion Strategy for Scroll Motion
- 06Scroll Timeline Performance and Main Thread Cost
- 07Progressive Enhancement with Supports Gating
- 08Debugging a Timeline That Does Not Run
- 09Scroll Timeline Reference and Progress Binding
- 10Named Scroll Timelines Across Elements
- 11View Timeline Subject and Scroller
- 12Animation Range Start and End Control
- 13Scroll Timeline Axis and Horizontal Scrollers
- 14The Four View Timeline Ranges Compared
- 15Timeline Scope Across DOM Branches
- 16Non Root Scroll Containers and Overflow
- 17Sticky Pinned Scene Scrubbing
- 18Fill Mode and Why Duration Is Ignored
- 19Perspective and Transform on a Scroll Timeline
- 20Layered Depth with Divergent Ranges
Perspective and Transform on a Scroll Timeline
Binding rotateX and translateZ to a view timeline gives a card that turns as it arrives — but only if the two properties that make 3D work are on the right elements. perspective belongs to the parent, transform-style: preserve-3d to the transformed element, and getting either wrong flattens the whole scene.
Build UI like this from scratch: Flexbox, Grid, animations & responsive CSS
ZTM · The CSS Bootcamp · 30‑day money-back guarantee · Affiliate link
The code
<section class="sa-19" aria-labelledby="sa-19-h">
<fieldset class="sa-19__theme">
<legend class="sa-19__themelegend">Theme</legend>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-auto" checked>
<label class="sa-19__themeopt" for="sa-19-theme-auto">Auto</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-light">
<label class="sa-19__themeopt" for="sa-19-theme-light">Light</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-dark">
<label class="sa-19__themeopt" for="sa-19-theme-dark">Dark</label>
</fieldset>
<div class="sa-19__stage">
<header class="sa-19__head">
<p class="sa-19__eyebrow">perspective · preserve-3d</p>
<h2 class="sa-19__h" id="sa-19-h">Depth bound to a view timeline</h2>
<p class="sa-19__sub">Two properties on two different elements. <span class="sa-19__mono">perspective</span> on the scene, <span class="sa-19__mono">transform-style: preserve-3d</span> on the card. The crosshair marks <span class="sa-19__mono">perspective-origin</span>.</p>
</header>
<div class="sa-19__scroller" tabindex="0" role="region" aria-label="Scroll driven 3D cards, scrollable">
<div class="sa-19__scene">
<span class="sa-19__origin" aria-hidden="true"></span>
<div class="sa-19__cards">
<article class="sa-19__card">
<p class="sa-19__ck">Nova · api-edge</p>
<p class="sa-19__cv">99.982%</p>
<p class="sa-19__cm">30-day availability</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Talon · queue depth</p>
<p class="sa-19__cv">412</p>
<p class="sa-19__cm">peak, last 24h</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Sable · deploys</p>
<p class="sa-19__cv">37</p>
<p class="sa-19__cm">this week · Jordan Lee</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Quill · error budget</p>
<p class="sa-19__cv">68%</p>
<p class="sa-19__cm">remaining this quarter</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Fern · build time</p>
<p class="sa-19__cv">4m 12s</p>
<p class="sa-19__cm">median, main branch</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Vale · invoice run</p>
<p class="sa-19__cv">1,204</p>
<p class="sa-19__cm">accounts settled · Priya Sharma</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
</div>
<p class="sa-19__foot"><span class="sa-19__mono">animation-range: entry 10% cover 55%</span> — steepest on arrival, flat once landed.</p>
</div>
</div>
</div>
</section><section class="sa-19" aria-labelledby="sa-19-h">
<fieldset class="sa-19__theme">
<legend class="sa-19__themelegend">Theme</legend>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-auto" checked>
<label class="sa-19__themeopt" for="sa-19-theme-auto">Auto</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-light">
<label class="sa-19__themeopt" for="sa-19-theme-light">Light</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-dark">
<label class="sa-19__themeopt" for="sa-19-theme-dark">Dark</label>
</fieldset>
<div class="sa-19__stage">
<header class="sa-19__head">
<p class="sa-19__eyebrow">perspective · preserve-3d</p>
<h2 class="sa-19__h" id="sa-19-h">Depth bound to a view timeline</h2>
<p class="sa-19__sub">Two properties on two different elements. <span class="sa-19__mono">perspective</span> on the scene, <span class="sa-19__mono">transform-style: preserve-3d</span> on the card. The crosshair marks <span class="sa-19__mono">perspective-origin</span>.</p>
</header>
<div class="sa-19__scroller" tabindex="0" role="region" aria-label="Scroll driven 3D cards, scrollable">
<div class="sa-19__scene">
<span class="sa-19__origin" aria-hidden="true"></span>
<div class="sa-19__cards">
<article class="sa-19__card">
<p class="sa-19__ck">Nova · api-edge</p>
<p class="sa-19__cv">99.982%</p>
<p class="sa-19__cm">30-day availability</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Talon · queue depth</p>
<p class="sa-19__cv">412</p>
<p class="sa-19__cm">peak, last 24h</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Sable · deploys</p>
<p class="sa-19__cv">37</p>
<p class="sa-19__cm">this week · Jordan Lee</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Quill · error budget</p>
<p class="sa-19__cv">68%</p>
<p class="sa-19__cm">remaining this quarter</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Fern · build time</p>
<p class="sa-19__cv">4m 12s</p>
<p class="sa-19__cm">median, main branch</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Vale · invoice run</p>
<p class="sa-19__cv">1,204</p>
<p class="sa-19__cm">accounts settled · Priya Sharma</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
</div>
<p class="sa-19__foot"><span class="sa-19__mono">animation-range: entry 10% cover 55%</span> — steepest on arrival, flat once landed.</p>
</div>
</div>
</div>
</section>.sa-19 {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
--page: oklch(13% 0.05 300);
--surface: oklch(20% 0.07 300);
--ink: oklch(94% 0.02 300);
--muted: oklch(68% 0.09 300);
--rule: oklch(30% 0.08 300);
--accent: oklch(70% 0.2 305);
--accent2: oklch(85% 0.15 200);
--sa-19-persp: 900px;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
position: relative;
padding: 4rem 1rem 3rem;
background: var(--page);
background-image: radial-gradient(45rem 22rem at 80% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%);
color: var(--ink);
font-family: system-ui, sans-serif;
}
.sa-19 *,
.sa-19 *::before,
.sa-19 *::after {
box-sizing: border-box;
}
.sa-19__stage {
max-inline-size: 54rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.sa-19__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sa-19__eyebrow {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent2);
}
.sa-19__h {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.375rem, 4vw, 2rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.12;
text-shadow: 0 0 2rem color-mix(in srgb, var(--accent) 55%, transparent);
}
.sa-19__sub {
margin: 0;
max-inline-size: 44rem;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sa-19__mono {
font-family: var(--font-display);
font-size: 0.85em;
color: var(--accent2);
}
.sa-19__scroller {
block-size: 60vh;
min-block-size: 20rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--rule);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--surface) 70%, transparent);
box-shadow: inset 0 0 4rem color-mix(in srgb, var(--accent) 12%, transparent);
scroll-timeline: --sa-19-track block;
}
.sa-19__scroller:focus-visible {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
.sa-19__scene {
position: relative;
min-block-size: calc(100% + 4rem);
padding: 3rem 1.25rem 4rem;
perspective: var(--sa-19-persp);
perspective-origin: 50% 40%;
}
.sa-19__origin {
position: absolute;
inset-block-start: 40%;
inset-inline-start: 50%;
inline-size: 1.5rem;
block-size: 1.5rem;
margin: -0.75rem 0 0 -0.75rem;
border-radius: 999px;
border: 1px dashed var(--accent2);
}
.sa-19__origin::before,
.sa-19__origin::after {
content: "";
position: absolute;
background: var(--accent2);
}
.sa-19__origin::before {
inset-block: -0.5rem;
inset-inline-start: 50%;
inline-size: 1px;
}
.sa-19__origin::after {
inset-inline: -0.5rem;
inset-block-start: 50%;
block-size: 1px;
}
.sa-19__cards {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.sa-19__card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1.25rem 1.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: 0.75rem;
background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 18%, var(--surface)), var(--surface));
box-shadow: 0 0 2.5rem -0.5rem color-mix(in srgb, var(--accent) 50%, transparent);
transform-style: preserve-3d;
backface-visibility: hidden;
transform: none;
min-inline-size: 0;
}
.sa-19__ck {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.sa-19__cv {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.75rem, 6vw, 2.5rem);
font-weight: 500;
letter-spacing: -0.04em;
color: var(--ink);
}
.sa-19__cm {
margin: 0;
font-size: 0.8125rem;
color: var(--muted);
}
.sa-19__badge {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.25rem;
background: var(--accent2);
color: #06121a;
font-family: var(--font-display);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
transform: translateZ(2.5rem);
}
.sa-19__foot {
margin: 2.5rem 0 0;
font-size: 0.75rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
@keyframes sa-19-turn {
from {
transform: rotateX(24deg) translateZ(-4rem);
opacity: 0.35;
}
to {
transform: rotateX(0deg) translateZ(0);
opacity: 1;
}
}
@supports (animation-timeline: view()) {
.sa-19__card {
animation: sa-19-turn linear both;
animation-timeline: view(block);
animation-range: entry 10% cover 55%;
}
}
@media (prefers-reduced-motion: reduce) {
.sa-19__card {
animation: none;
transform: none;
opacity: 1;
}
}
.sa-19__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
display: inline-flex;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid var(--rule);
border-radius: 0.5rem;
background: var(--surface);
}
.sa-19__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.625rem;
border-radius: 0.375rem;
font-family: var(--font-display);
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.sa-19__themeinput:checked + .sa-19__themeopt {
color: var(--page);
background: var(--accent);
}
.sa-19__themeinput:focus-visible + .sa-19__themeopt {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
@media (max-width: 34rem) {
.sa-19 {
--sa-19-persp: 600px;
padding-block-start: 4.5rem;
}
.sa-19__cards {
gap: 1.75rem;
}
}
@media (forced-colors: active) {
.sa-19__card {
border: 1px solid CanvasText;
box-shadow: none;
}
}
@media (prefers-color-scheme: light) {
.sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__badge {
color: #ffffff;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
@media (prefers-color-scheme: dark) {
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
[data-theme="light"] .sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
.sa-19:has(#sa-19-theme-dark:checked) {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
}
.sa-19:has(#sa-19-theme-dark:checked) .sa-19__badge {
color: #06121a;
}.sa-19 {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
--page: oklch(13% 0.05 300);
--surface: oklch(20% 0.07 300);
--ink: oklch(94% 0.02 300);
--muted: oklch(68% 0.09 300);
--rule: oklch(30% 0.08 300);
--accent: oklch(70% 0.2 305);
--accent2: oklch(85% 0.15 200);
--sa-19-persp: 900px;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
position: relative;
padding: 4rem 1rem 3rem;
background: var(--page);
background-image: radial-gradient(45rem 22rem at 80% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%);
color: var(--ink);
font-family: system-ui, sans-serif;
}
.sa-19 *,
.sa-19 *::before,
.sa-19 *::after {
box-sizing: border-box;
}
.sa-19__stage {
max-inline-size: 54rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.sa-19__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sa-19__eyebrow {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent2);
}
.sa-19__h {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.375rem, 4vw, 2rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.12;
text-shadow: 0 0 2rem color-mix(in srgb, var(--accent) 55%, transparent);
}
.sa-19__sub {
margin: 0;
max-inline-size: 44rem;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sa-19__mono {
font-family: var(--font-display);
font-size: 0.85em;
color: var(--accent2);
}
.sa-19__scroller {
block-size: 60vh;
min-block-size: 20rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--rule);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--surface) 70%, transparent);
box-shadow: inset 0 0 4rem color-mix(in srgb, var(--accent) 12%, transparent);
scroll-timeline: --sa-19-track block;
}
.sa-19__scroller:focus-visible {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
.sa-19__scene {
position: relative;
min-block-size: calc(100% + 4rem);
padding: 3rem 1.25rem 4rem;
perspective: var(--sa-19-persp);
perspective-origin: 50% 40%;
}
.sa-19__origin {
position: absolute;
inset-block-start: 40%;
inset-inline-start: 50%;
inline-size: 1.5rem;
block-size: 1.5rem;
margin: -0.75rem 0 0 -0.75rem;
border-radius: 999px;
border: 1px dashed var(--accent2);
}
.sa-19__origin::before,
.sa-19__origin::after {
content: "";
position: absolute;
background: var(--accent2);
}
.sa-19__origin::before {
inset-block: -0.5rem;
inset-inline-start: 50%;
inline-size: 1px;
}
.sa-19__origin::after {
inset-inline: -0.5rem;
inset-block-start: 50%;
block-size: 1px;
}
.sa-19__cards {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.sa-19__card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1.25rem 1.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: 0.75rem;
background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 18%, var(--surface)), var(--surface));
box-shadow: 0 0 2.5rem -0.5rem color-mix(in srgb, var(--accent) 50%, transparent);
transform-style: preserve-3d;
backface-visibility: hidden;
transform: none;
min-inline-size: 0;
}
.sa-19__ck {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.sa-19__cv {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.75rem, 6vw, 2.5rem);
font-weight: 500;
letter-spacing: -0.04em;
color: var(--ink);
}
.sa-19__cm {
margin: 0;
font-size: 0.8125rem;
color: var(--muted);
}
.sa-19__badge {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.25rem;
background: var(--accent2);
color: #06121a;
font-family: var(--font-display);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
transform: translateZ(2.5rem);
}
.sa-19__foot {
margin: 2.5rem 0 0;
font-size: 0.75rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
@keyframes sa-19-turn {
from {
transform: rotateX(24deg) translateZ(-4rem);
opacity: 0.35;
}
to {
transform: rotateX(0deg) translateZ(0);
opacity: 1;
}
}
@supports (animation-timeline: view()) {
.sa-19__card {
animation: sa-19-turn linear both;
animation-timeline: view(block);
animation-range: entry 10% cover 55%;
}
}
@media (prefers-reduced-motion: reduce) {
.sa-19__card {
animation: none;
transform: none;
opacity: 1;
}
}
.sa-19__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
display: inline-flex;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid var(--rule);
border-radius: 0.5rem;
background: var(--surface);
}
.sa-19__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.625rem;
border-radius: 0.375rem;
font-family: var(--font-display);
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.sa-19__themeinput:checked + .sa-19__themeopt {
color: var(--page);
background: var(--accent);
}
.sa-19__themeinput:focus-visible + .sa-19__themeopt {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
@media (max-width: 34rem) {
.sa-19 {
--sa-19-persp: 600px;
padding-block-start: 4.5rem;
}
.sa-19__cards {
gap: 1.75rem;
}
}
@media (forced-colors: active) {
.sa-19__card {
border: 1px solid CanvasText;
box-shadow: none;
}
}
@media (prefers-color-scheme: light) {
.sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__badge {
color: #ffffff;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
@media (prefers-color-scheme: dark) {
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
[data-theme="light"] .sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
.sa-19:has(#sa-19-theme-dark:checked) {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
}
.sa-19:has(#sa-19-theme-dark:checked) .sa-19__badge {
color: #06121a;
}Here's a working CSS Scroll Animation 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: Perspective and Transform on a Scroll Timeline
Source: https://codefronts.com/motion/css-scroll-animations/3d-card-entrances/
Binding rotateX and translateZ to a view timeline gives a card that turns as it arrives — but only if the two properties that make 3D work are on the right elements. perspective belongs to the parent, transform-style: preserve-3d to the transformed element, and getting either wrong flattens the whole scene.
## HTML
```html
<section class="sa-19" aria-labelledby="sa-19-h">
<fieldset class="sa-19__theme">
<legend class="sa-19__themelegend">Theme</legend>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-auto" checked>
<label class="sa-19__themeopt" for="sa-19-theme-auto">Auto</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-light">
<label class="sa-19__themeopt" for="sa-19-theme-light">Light</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-dark">
<label class="sa-19__themeopt" for="sa-19-theme-dark">Dark</label>
</fieldset>
<div class="sa-19__stage">
<header class="sa-19__head">
<p class="sa-19__eyebrow">perspective · preserve-3d</p>
<h2 class="sa-19__h" id="sa-19-h">Depth bound to a view timeline</h2>
<p class="sa-19__sub">Two properties on two different elements. <span class="sa-19__mono">perspective</span> on the scene, <span class="sa-19__mono">transform-style: preserve-3d</span> on the card. The crosshair marks <span class="sa-19__mono">perspective-origin</span>.</p>
</header>
<div class="sa-19__scroller" tabindex="0" role="region" aria-label="Scroll driven 3D cards, scrollable">
<div class="sa-19__scene">
<span class="sa-19__origin" aria-hidden="true"></span>
<div class="sa-19__cards">
<article class="sa-19__card">
<p class="sa-19__ck">Nova · api-edge</p>
<p class="sa-19__cv">99.982%</p>
<p class="sa-19__cm">30-day availability</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Talon · queue depth</p>
<p class="sa-19__cv">412</p>
<p class="sa-19__cm">peak, last 24h</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Sable · deploys</p>
<p class="sa-19__cv">37</p>
<p class="sa-19__cm">this week · Jordan Lee</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Quill · error budget</p>
<p class="sa-19__cv">68%</p>
<p class="sa-19__cm">remaining this quarter</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Fern · build time</p>
<p class="sa-19__cv">4m 12s</p>
<p class="sa-19__cm">median, main branch</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Vale · invoice run</p>
<p class="sa-19__cv">1,204</p>
<p class="sa-19__cm">accounts settled · Priya Sharma</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
</div>
<p class="sa-19__foot"><span class="sa-19__mono">animation-range: entry 10% cover 55%</span> — steepest on arrival, flat once landed.</p>
</div>
</div>
</div>
</section>
```
## CSS
```css
.sa-19 {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
--page: oklch(13% 0.05 300);
--surface: oklch(20% 0.07 300);
--ink: oklch(94% 0.02 300);
--muted: oklch(68% 0.09 300);
--rule: oklch(30% 0.08 300);
--accent: oklch(70% 0.2 305);
--accent2: oklch(85% 0.15 200);
--sa-19-persp: 900px;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
position: relative;
padding: 4rem 1rem 3rem;
background: var(--page);
background-image: radial-gradient(45rem 22rem at 80% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%);
color: var(--ink);
font-family: system-ui, sans-serif;
}
.sa-19 *,
.sa-19 *::before,
.sa-19 *::after {
box-sizing: border-box;
}
.sa-19__stage {
max-inline-size: 54rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.sa-19__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sa-19__eyebrow {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent2);
}
.sa-19__h {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.375rem, 4vw, 2rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.12;
text-shadow: 0 0 2rem color-mix(in srgb, var(--accent) 55%, transparent);
}
.sa-19__sub {
margin: 0;
max-inline-size: 44rem;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sa-19__mono {
font-family: var(--font-display);
font-size: 0.85em;
color: var(--accent2);
}
.sa-19__scroller {
block-size: 60vh;
min-block-size: 20rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--rule);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--surface) 70%, transparent);
box-shadow: inset 0 0 4rem color-mix(in srgb, var(--accent) 12%, transparent);
scroll-timeline: --sa-19-track block;
}
.sa-19__scroller:focus-visible {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
.sa-19__scene {
position: relative;
min-block-size: calc(100% + 4rem);
padding: 3rem 1.25rem 4rem;
perspective: var(--sa-19-persp);
perspective-origin: 50% 40%;
}
.sa-19__origin {
position: absolute;
inset-block-start: 40%;
inset-inline-start: 50%;
inline-size: 1.5rem;
block-size: 1.5rem;
margin: -0.75rem 0 0 -0.75rem;
border-radius: 999px;
border: 1px dashed var(--accent2);
}
.sa-19__origin::before,
.sa-19__origin::after {
content: "";
position: absolute;
background: var(--accent2);
}
.sa-19__origin::before {
inset-block: -0.5rem;
inset-inline-start: 50%;
inline-size: 1px;
}
.sa-19__origin::after {
inset-inline: -0.5rem;
inset-block-start: 50%;
block-size: 1px;
}
.sa-19__cards {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.sa-19__card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1.25rem 1.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: 0.75rem;
background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 18%, var(--surface)), var(--surface));
box-shadow: 0 0 2.5rem -0.5rem color-mix(in srgb, var(--accent) 50%, transparent);
transform-style: preserve-3d;
backface-visibility: hidden;
transform: none;
min-inline-size: 0;
}
.sa-19__ck {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.sa-19__cv {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.75rem, 6vw, 2.5rem);
font-weight: 500;
letter-spacing: -0.04em;
color: var(--ink);
}
.sa-19__cm {
margin: 0;
font-size: 0.8125rem;
color: var(--muted);
}
.sa-19__badge {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.25rem;
background: var(--accent2);
color: #06121a;
font-family: var(--font-display);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
transform: translateZ(2.5rem);
}
.sa-19__foot {
margin: 2.5rem 0 0;
font-size: 0.75rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
@keyframes sa-19-turn {
from {
transform: rotateX(24deg) translateZ(-4rem);
opacity: 0.35;
}
to {
transform: rotateX(0deg) translateZ(0);
opacity: 1;
}
}
@supports (animation-timeline: view()) {
.sa-19__card {
animation: sa-19-turn linear both;
animation-timeline: view(block);
animation-range: entry 10% cover 55%;
}
}
@media (prefers-reduced-motion: reduce) {
.sa-19__card {
animation: none;
transform: none;
opacity: 1;
}
}
.sa-19__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
display: inline-flex;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid var(--rule);
border-radius: 0.5rem;
background: var(--surface);
}
.sa-19__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.625rem;
border-radius: 0.375rem;
font-family: var(--font-display);
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.sa-19__themeinput:checked + .sa-19__themeopt {
color: var(--page);
background: var(--accent);
}
.sa-19__themeinput:focus-visible + .sa-19__themeopt {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
@media (max-width: 34rem) {
.sa-19 {
--sa-19-persp: 600px;
padding-block-start: 4.5rem;
}
.sa-19__cards {
gap: 1.75rem;
}
}
@media (forced-colors: active) {
.sa-19__card {
border: 1px solid CanvasText;
box-shadow: none;
}
}
@media (prefers-color-scheme: light) {
.sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__badge {
color: #ffffff;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
@media (prefers-color-scheme: dark) {
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
[data-theme="light"] .sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
.sa-19:has(#sa-19-theme-dark:checked) {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
}
.sa-19:has(#sa-19-theme-dark:checked) .sa-19__badge {
color: #06121a;
}
```Here's a working CSS Scroll Animation 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: Perspective and Transform on a Scroll Timeline
Source: https://codefronts.com/motion/css-scroll-animations/3d-card-entrances/
Binding rotateX and translateZ to a view timeline gives a card that turns as it arrives — but only if the two properties that make 3D work are on the right elements. perspective belongs to the parent, transform-style: preserve-3d to the transformed element, and getting either wrong flattens the whole scene.
## HTML
```html
<section class="sa-19" aria-labelledby="sa-19-h">
<fieldset class="sa-19__theme">
<legend class="sa-19__themelegend">Theme</legend>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-auto" checked>
<label class="sa-19__themeopt" for="sa-19-theme-auto">Auto</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-light">
<label class="sa-19__themeopt" for="sa-19-theme-light">Light</label>
<input class="sa-19__themeinput" type="radio" name="sa-19-theme" id="sa-19-theme-dark">
<label class="sa-19__themeopt" for="sa-19-theme-dark">Dark</label>
</fieldset>
<div class="sa-19__stage">
<header class="sa-19__head">
<p class="sa-19__eyebrow">perspective · preserve-3d</p>
<h2 class="sa-19__h" id="sa-19-h">Depth bound to a view timeline</h2>
<p class="sa-19__sub">Two properties on two different elements. <span class="sa-19__mono">perspective</span> on the scene, <span class="sa-19__mono">transform-style: preserve-3d</span> on the card. The crosshair marks <span class="sa-19__mono">perspective-origin</span>.</p>
</header>
<div class="sa-19__scroller" tabindex="0" role="region" aria-label="Scroll driven 3D cards, scrollable">
<div class="sa-19__scene">
<span class="sa-19__origin" aria-hidden="true"></span>
<div class="sa-19__cards">
<article class="sa-19__card">
<p class="sa-19__ck">Nova · api-edge</p>
<p class="sa-19__cv">99.982%</p>
<p class="sa-19__cm">30-day availability</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Talon · queue depth</p>
<p class="sa-19__cv">412</p>
<p class="sa-19__cm">peak, last 24h</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Sable · deploys</p>
<p class="sa-19__cv">37</p>
<p class="sa-19__cm">this week · Jordan Lee</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Quill · error budget</p>
<p class="sa-19__cv">68%</p>
<p class="sa-19__cm">remaining this quarter</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Fern · build time</p>
<p class="sa-19__cv">4m 12s</p>
<p class="sa-19__cm">median, main branch</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
<article class="sa-19__card">
<p class="sa-19__ck">Vale · invoice run</p>
<p class="sa-19__cv">1,204</p>
<p class="sa-19__cm">accounts settled · Priya Sharma</p>
<span class="sa-19__badge">preserve-3d</span>
</article>
</div>
<p class="sa-19__foot"><span class="sa-19__mono">animation-range: entry 10% cover 55%</span> — steepest on arrival, flat once landed.</p>
</div>
</div>
</div>
</section>
```
## CSS
```css
.sa-19 {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
--page: oklch(13% 0.05 300);
--surface: oklch(20% 0.07 300);
--ink: oklch(94% 0.02 300);
--muted: oklch(68% 0.09 300);
--rule: oklch(30% 0.08 300);
--accent: oklch(70% 0.2 305);
--accent2: oklch(85% 0.15 200);
--sa-19-persp: 900px;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
position: relative;
padding: 4rem 1rem 3rem;
background: var(--page);
background-image: radial-gradient(45rem 22rem at 80% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%);
color: var(--ink);
font-family: system-ui, sans-serif;
}
.sa-19 *,
.sa-19 *::before,
.sa-19 *::after {
box-sizing: border-box;
}
.sa-19__stage {
max-inline-size: 54rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.sa-19__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sa-19__eyebrow {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent2);
}
.sa-19__h {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.375rem, 4vw, 2rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.12;
text-shadow: 0 0 2rem color-mix(in srgb, var(--accent) 55%, transparent);
}
.sa-19__sub {
margin: 0;
max-inline-size: 44rem;
font-size: 0.875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sa-19__mono {
font-family: var(--font-display);
font-size: 0.85em;
color: var(--accent2);
}
.sa-19__scroller {
block-size: 60vh;
min-block-size: 20rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--rule);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--surface) 70%, transparent);
box-shadow: inset 0 0 4rem color-mix(in srgb, var(--accent) 12%, transparent);
scroll-timeline: --sa-19-track block;
}
.sa-19__scroller:focus-visible {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
.sa-19__scene {
position: relative;
min-block-size: calc(100% + 4rem);
padding: 3rem 1.25rem 4rem;
perspective: var(--sa-19-persp);
perspective-origin: 50% 40%;
}
.sa-19__origin {
position: absolute;
inset-block-start: 40%;
inset-inline-start: 50%;
inline-size: 1.5rem;
block-size: 1.5rem;
margin: -0.75rem 0 0 -0.75rem;
border-radius: 999px;
border: 1px dashed var(--accent2);
}
.sa-19__origin::before,
.sa-19__origin::after {
content: "";
position: absolute;
background: var(--accent2);
}
.sa-19__origin::before {
inset-block: -0.5rem;
inset-inline-start: 50%;
inline-size: 1px;
}
.sa-19__origin::after {
inset-inline: -0.5rem;
inset-block-start: 50%;
block-size: 1px;
}
.sa-19__cards {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.sa-19__card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1.25rem 1.25rem 1.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: 0.75rem;
background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 18%, var(--surface)), var(--surface));
box-shadow: 0 0 2.5rem -0.5rem color-mix(in srgb, var(--accent) 50%, transparent);
transform-style: preserve-3d;
backface-visibility: hidden;
transform: none;
min-inline-size: 0;
}
.sa-19__ck {
margin: 0;
font-family: var(--font-display);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.sa-19__cv {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.75rem, 6vw, 2.5rem);
font-weight: 500;
letter-spacing: -0.04em;
color: var(--ink);
}
.sa-19__cm {
margin: 0;
font-size: 0.8125rem;
color: var(--muted);
}
.sa-19__badge {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.25rem;
background: var(--accent2);
color: #06121a;
font-family: var(--font-display);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
transform: translateZ(2.5rem);
}
.sa-19__foot {
margin: 2.5rem 0 0;
font-size: 0.75rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
@keyframes sa-19-turn {
from {
transform: rotateX(24deg) translateZ(-4rem);
opacity: 0.35;
}
to {
transform: rotateX(0deg) translateZ(0);
opacity: 1;
}
}
@supports (animation-timeline: view()) {
.sa-19__card {
animation: sa-19-turn linear both;
animation-timeline: view(block);
animation-range: entry 10% cover 55%;
}
}
@media (prefers-reduced-motion: reduce) {
.sa-19__card {
animation: none;
transform: none;
opacity: 1;
}
}
.sa-19__theme {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: 9;
display: inline-flex;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid var(--rule);
border-radius: 0.5rem;
background: var(--surface);
}
.sa-19__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sa-19__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.625rem;
border-radius: 0.375rem;
font-family: var(--font-display);
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.sa-19__themeinput:checked + .sa-19__themeopt {
color: var(--page);
background: var(--accent);
}
.sa-19__themeinput:focus-visible + .sa-19__themeopt {
outline: 2px solid var(--accent2);
outline-offset: 2px;
}
@media (max-width: 34rem) {
.sa-19 {
--sa-19-persp: 600px;
padding-block-start: 4.5rem;
}
.sa-19__cards {
gap: 1.75rem;
}
}
@media (forced-colors: active) {
.sa-19__card {
border: 1px solid CanvasText;
box-shadow: none;
}
}
@media (prefers-color-scheme: light) {
.sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__badge {
color: #ffffff;
}
.sa-19:not(:has(#sa-19-theme-dark:checked)) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
@media (prefers-color-scheme: dark) {
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
}
[data-theme="light"] .sa-19:not(:has(#sa-19-theme-dark:checked)) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) {
--page: #f6f2ff;
--surface: #ffffff;
--ink: #17102c;
--muted: #61548c;
--rule: #ded4f6;
--accent: #7b3fe4;
--accent2: #0a7f96;
}
.sa-19:has(#sa-19-theme-light:checked) .sa-19__badge,
.sa-19:has(#sa-19-theme-light:checked) .sa-19__themeinput:checked + .sa-19__themeopt {
color: #ffffff;
}
.sa-19:has(#sa-19-theme-dark:checked) {
--page: #0a0616;
--surface: #140c2a;
--ink: #f0eaff;
--muted: #9c8cd4;
--rule: #2a1c52;
--accent: #b06cff;
--accent2: #2ee6ff;
}
.sa-19:has(#sa-19-theme-dark:checked) .sa-19__badge {
color: #06121a;
}
```How this works
Perspective lives on the parent. perspective: 900px on the container sets how far the viewer is from the z=0 plane, and every child transformed in 3D is rendered against that. Putting perspective on the rotating element instead gives that element its own local perspective for its children — the element itself still rotates without any foreshortening, which is why the result looks like a flat skew rather than a turn.
transform-style: preserve-3d keeps children in the same 3D space. Without it, a transformed element flattens its subtree onto its own plane, so a nested badge that should hang in front of the card gets painted onto it. Set it on the element whose children need depth, not on the perspective container.
The rotation is bound, not triggered. animation-timeline: view(block) with animation-range: entry 10% cover 55% means the card is at its steepest angle as it enters and lands flat somewhat past the middle of its passage. Scroll back up and it tilts away again — a triggered entrance could not do that.
The trap is perspective on the wrong ancestor. Any ancestor between the perspective container and the transformed element that creates a new flattening context — a plain transform, filter, opacity below 1, overflow: hidden with a transform — resets the 3D rendering context. The rotation still runs, it just renders flat. When a 3D scroll animation looks like a 2D squash, the problem is almost never the keyframes.
Make it yours
- Lower
--sa-19-perspfrom 900px to 400px for a far more dramatic, wide-angle turn. - Move
perspectiveonto the card itself to reproduce the flattening bug on purpose. - Change
perspective-origintotop centerand the vanishing point marker follows. - Widen
animation-rangetocoverso the card is still turning as it leaves. - Swap
rotateXforrotateYto turn on the vertical axis instead. - Retint
--sa-19-accentand--sa-19-accent2for a different two-hue glow.
Gotchas — read before shipping
perspectiveon the rotating element instead of its parent produces a flat skew, not a turn — the most common 3D scroll bug.- An intermediate ancestor with a transform, filter or
opacitybelow 1 flattens the 3D context and silently kills the depth. - Without
transform-style: preserve-3dnested layers are painted onto the card's plane rather than floating in front of it. - Steep rotations without
backface-visibility: hiddenflash the reverse face at the extremes of the range.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 115+ | not yet | 136+ | 115+ |
animation-timeline: view() sets the floor at Chrome 115, Firefox 136 unflagged, Safari not shipped. The 3D machinery — perspective, transform-style, backface-visibility — is Baseline back to 2015 and works in Safari, so only the binding is missing there. The base layer declares the card flat and settled inside its frame with the perspective-origin marker still drawn, so Safari shows the finished composition rather than a card frozen at a steep angle.