Building a CSS Scroll Snap Slide Deck With Live Code Editing
Presenters are used to adapting their workflow between virtual and in-person events. At home, multiple monitors allow you to prep code demos off-screen before revealing them. Back at a live venue, you're likely limited to a single laptop screen with no such luxury.
With a combination of native web functionality and modern CSS—including scroll snap—you can build a no-JavaScript slide deck that supports live editing of CSS demos. The entire deck is responsive and shareable, living inside a CodePen.
The approach relies on a handful of techniques:
- CSS scroll snap, counters, and grid layout
- The
contenteditableattribute - Custom properties with HSL for theming
- Gradient text using
background-clip - Displaying and styling the
<style>element itself
Slide Templates
A slide deck usually needs more than one layout. These three templates cover the essentials:
- Text: flexible content for any kind of narrative slide
- Title: large headline slides to break up sections
- Demo: split layout with a code block alongside its live preview



The HTML for Basic Slides
The deck is an ordered list with the ID slides. Each list item carries the class slide plus a modifier class describing its template type. For text-based slides, a <div> with the class content wraps the copy:
<ol id="slides">
<li class="slide slide--text">
<div class="content">
<h1>Presentation Title</h1>
<p>Presented by Your Name</p>
<p><a target="_blank" href="<https://twitter.com/5t3ph>">@5t3ph</a></p>
</div>
</li>
<li class="slide slide--title">
<div class="content">
<h2>Topic 1</h2>
</div>
</li>
</ol>
Links inside the slides use target="_blank" because CodePen displays the deck inside an iframe—that attribute "escapes" the iframe when a viewer clicks through.
Base Styles and Theming
These styles assume you're not loading a CSS reset in CodePen. The reset removes margin and lets the <body> fill the full viewport height, plus a basic font stack:
* {
margin: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
font-family: system-ui, sans-serif;
font-size: 1.5rem;
}
All major layout elements use CSS grid. List styling is removed from #slides, and each slide fills the viewport. The place-content shorthand centers content inside slide--text and slide--title slides:
body,
#slides,
.slide {
display: grid;
}
#slides {
list-style: none;
padding: 0;
margin: 0;
}
.slide {
width: 100vw;
height: 100vh;
}
.slide--text,
.slide--title {
place-content: center;
}
Because a presentation typically makes one point at a time, the base font-size is bumped to 2rem. Test the size at full screen before finalizing—content volume and projection size both matter:
h1, h2 {
line-height: 1.1;
}
a {
color: inherit;
}
.content {
padding: 2rem;
font-size: 2rem;
line-height: 1.5;
}
.content * + * {
margin-top: 0.5em;
}
.slide--text .content {
max-width: 40ch;
}
For a cohesive look, colors are driven by the hsl color space. Custom properties hold the --theme-hue and --theme-saturation; they combine into --theme-hs for reuse:
:root {
--theme-hue: 230;
--theme-saturation: 85%;
--theme-hs: var(--theme-hue), var(--theme-saturation);
}
Lightness values are then adjusted per use—light for backgrounds, dark for text—so all slides stay well-tinted variations of the same base hue:
body {
/* ... existing styles */
background-color: hsl(var(--theme-hs), 95%);
color: hsl(var(--theme-hs), 25%);
}

The title slide gets a subtle gradient background for extra distinction:
.slide--title {
background-image:
linear-gradient(125deg,
hsl(var(--theme-hs), 95%),
hsl(var(--theme-hs), 75%)
);
}

The Live-Coding Demo Slide
The demo slide is the most involved template. Two elements do the heavy lifting:
- a
.stylecontainer holding an actual inline<style>element—whose contents are visible on screen and also applied to the demo - a
.democontainer holding the preview markup
In CodePen, turn off "Format on Save" in the Behavior settings. Extra tabs or spaces before the style block would mess up the presentation, as will become clear below.

Here is example demo slide markup:
<li class="slide slide--demo">
<div class="style">
<style contenteditable="true">
.modern-container {
--container-width: 40ch;
width: min(
var(--container-width), 100% - 3rem
);
margin-inline: auto;
}
</style>
</div>
<div class="demo">
<div class="modern-container">
<div class="box">container</div>
</div>
</div>
</li>
The key attribute on the <style> block is contenteditable="true". This is native HTML behavior that makes any element editable—not a replacement for form controls, but exactly the right tool here. Edits to the visible style text apply immediately.
Without extra styles, the <style> block itself is invisible; only its effects on the demo markup show. That changes when the element is forced onto the page using display. The <style> element sits inside a resizable container and is styled to look like a code editor. Note that HTML5 permits <style> blocks outside the <head>:
.style {
display: grid;
align-items: center;
background-color: hsl(var(--theme-hs), 5%);
padding-inline: max(5vw, 2rem) 3rem;
font-size: 1.35rem;
overflow-y: hidden;
resize: horizontal;
}
style {
display: block;
outline: none;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
color: hsl(var(--theme-hs), 85%);
background: none;
white-space: pre;
line-height: 1.65;
tab-size: 2;
hyphens: none;
}
For the .slide--demo template, grid arranges styles and preview side-by-side, taking advantage of the grid setup from .slide:
.slide--demo {
grid-template-columns: fit-content(85ch) 1fr;
}
The fit-content() grid function lets the style column expand based on its content up to a maximum of 85ch. The column stays narrow when content is short, but won't grow past the cap:
.demo {
padding: 2rem;
}
.box {
background-color: hsl(var(--theme-hs), 85%);
border: 2px dashed;
border-radius: .5em;
padding: 1rem;
font-size: 1.35rem;
text-align: center;
}
Here's the result:

Editing Behavior Across Browsers
Editing the visible styles updates the rendered demo, and the resize handle on the .style container lets viewers adjust the preview area. However, browser support for live style editing is uneven:
- Firefox: fully supports changing values, adding new properties, and adding entire new rules
- Chromium and Safari: allow changing existing values, but not adding new properties or rules
For live presentations, Firefox is the safe choice. Viewers who open the deck later can still see the demos correctly, but outside Firefox they won't be able to manipulate them as thoroughly. When distributing a finished deck, consider forking the CodePen and removing the editable attribute from style blocks so final styles are simply displayed.
Demo styles can leak and affect other slides or the deck's layout. Scoping demo rules under a slide-specific class avoids unintended cross-slide interference.
Highlighting Code Without JavaScript
Full syntax highlighting needs JavaScript, but a targeted emphasis effect is achievable with CSS. The trick pairs gradient text—via the -webkit prefixed background-clip properties—with custom properties that mark certain lines for highlighting:
style {
/* ...existing styles */
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
}
The core idea: a linear-gradient shows a lighter text color on the lines to highlight, while other lines stay dark. The defaults make the first property appear highlighted:

Calculating the gradient position requires care. The <style> element uses line-height: 1.65, so the total line height is 1.65em. But due to white-space: pre preserving formatting, an invisible line precedes the first CSS rule—created by the newline after the opening <style> tag. That's also why CodePen's auto-formatting must stay off: it would introduce unwanted left padding.
Three custom properties compute the gradient geometry. The --lines calculation accounts for the invisible first line plus the selector line:
style {
--line-height: 1.65em;
--highlight-start: calc(2 * var(--line-height));
--lines: calc(var(--highlight-start) + var(--num-lines, 1) * var(--line-height));
}
Sharp gradient transitions make the highlight effect work. Gradient stops match exactly so the color switches crisply at each line boundary:
style {
background-image: linear-gradient(
hsl(var(--theme-hs), 75%) 0 var(--highlight-start),
hsl(var(--theme-hs), 90%) var(--highlight-start) var(--lines),
hsl(var(--theme-hs), 75%) var(--lines) 100%
);
}
Commenting out the -webkit properties shows the raw gradient behind the text:

The --num-lines property controls the highlight height per demo via an inline style. This example highlights three lines:
<style contenteditable="true" style="--num-lines: 3">
A --highlight-start value can shift which line begins the highlight:
<style contenteditable="true" style="--num-lines: 3; --highlight-start: calc(4 * var(--line-height))">
Here's that adjustment in action:

The highlight won't dynamically recalculate as lines are added or removed during a presentation, but it remains useful for directing attention. Two utility classes apply to the <style> element: one highlights just the current rule, the other removes highlighting entirely:
.highlight--rule-only {
--highlight-start: calc(1 * var(--line-height))
}
.highlight--none {
background-image: none;
background-color: currentColor;
}
Adding Slide Motion With CSS Scroll Snap
Two steps turn the stacked templates into an actual slide deck: reflowing slides horizontally and configuring scroll snap.
Since #slides is already defined as a grid container, one additional property enables horizontal flow:
#slides {
/* ...existing styles */
grid-auto-flow: column;
}
Scroll snap needs the overflow axis declared, which is x for horizontal:
#slides {
overflow-x: auto;
}
The scroll-snap-type shorthand selects the x axis with mandatory behavior, so any scroll gesture snaps to the next element:
#slides {
scroll-snap-type: x mandatory;
}
Snapping targets are set on each child .slide. The scroll-snap-align property defines the snap point; scroll-snap-stop: always prevents skimming past a slide in one swipe:
.slide {
/* ...existing styles */
scroll-snap-align: center;
scroll-snap-stop: always;
}
Scrolling or pressing the left/right arrow keys moves between slides. Be aware that scroll snap behavior varies across browsers and input devices—a trackpad feels different from a mouse wheel. If snapping isn't smooth, arrow keys are a reliable fallback.
Scroll snap doesn't expose control over animation easing or speed. If you must tune that and don't need the live-editing features, a standard presentation app is a better fit. There are additional scroll-snap properties beyond what's used here—MDN's CSS scroll snap documentation covers them all.
Slide Numbers
A CSS counter provides optional slide numbering. Counters track position without JavaScript and display through pseudo-elements; data attributes can append a short topic label.
The counter is named and incremented across the slides:
#slides {
counter-reset: slides;
}
.slide {
counter-increment: slides;
}
The counter() function inside a pseudo-element's content property outputs the current number:
.slide::before {
content: counter(slides);
}
Positioning and styling turn that number into a visible indicator in the corner:
.slide::before {
content: counter(slides);
position: absolute;
left: 1rem;
bottom: 1rem;
width: 1.65em;
height: 1.65em;
display: grid;
place-content: center;
border-radius: 50%;
font-size: 1.25rem;
color: hsl(var(--theme-hs), 95%);
background-color: hsl(var(--theme-hs), 55%);
}

Adding data-topic attributes to list items lets the number display alongside the section name. The attr() function reads the attribute value and concatenates it with the counter. Shorter topic strings display better:
<li class="slide slide--title">
[data-topic]::before {
content: counter(slides) ": " attr(data-topic);
padding: 0.25em 0.4em;
width: auto;
border-radius: 0.5rem;
}
Here's a demo slide with its topic label visible:

Handling Small Viewports
The deck scales well up to a point, but small screens need an alternative to horizontal panning. Removing scroll snap and letting slides flow vertically is the cleanest approach.
Scroll snap properties for #slides move into a media query applying only above 120ch:
@media screen and (min-width: 120ch) {
#slides {
grid-auto-flow: column;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
}
@media screen and (min-width: 120ch) {
.slide {
width: 100vw;
height: 100vh;
scroll-snap-align: center;
scroll-snap-stop: always;
}
}
Demo slides are likewise restyled to stack their content above that breakpoint:
@media screen and (min-width: 120ch) {
.slide--demo {
grid-template-columns: fit-content(85ch) 1fr;
}
}
Below the breakpoint, slides get a minimum height and a top border as a visual separator:
@media (max-width: 120ch) {
.slide {
min-height: 80vh;
}
.slide + .slide {
border-top: 1px dashed;
}
}
Overflow protection prevents cramped layouts on narrow screens. The .content width becomes fluid, the previous max-width constraint moves inside the media query, and typography switches to fluid sizing:
h1 {
font-size: clamp(2rem, 8vw + 1rem, 3.25rem);
}
.content {
/* remove max-width rule from here */
width: calc(100vw - 2rem);
}
@media screen and (min-width: 120ch) {
.content {
width: unset;
max-width: 45ch;
}
}
The slide-number badge relocates from the bottom to the top-left corner on small screens, then back at larger widths:
.slide {
/* adjust default here, removing the old "bottom" value */
top: 0.25rem;
left: 0.25rem;
}
@media (min-width: 120ch) {
.slide::before {
top: auto;
bottom: 1rem;
left: 1rem;
}
}
Putting It Together
The CodePen embed usually loads in its stacked small-viewport mode—open the standalone version to navigate the full scroll-snap experience. Reminder: for editing demos live, Firefox is the recommended browser.
This exact technique has been used for a full presentation on the modern CSS toolkit.



