View Transitions and an Astro Presentation Framework

06 March 2024

Updated: 17 April 2024

Well, since this is a post about building a presentation framework within your Astro site, it may be worth mentioning that you can view this page as a presentation using the below button:

The Problem

I’m kind of lazy.

I had to put together a little presentation based on something I’ve written about previously and wanted a lazy way to reuse my existing content while also making the resulting presentation available on my website

Overall, these are the requirements I had in mind:

My Requirements

  1. Not require any additional build process
  2. Work with Markdown or MDX so I can include it in my website easily
  3. Have a small learning curve
  4. Integrate flexibly with my existing content - pages should be able to be very easily converted to slides

So I investigated a few solutions:

Existing Slide Solutions

  1. Just a markdown doc
  2. Reveal.js
  3. MDX Deck
  4. Spectacle
  5. Plain’ ol’ HTML
  6. PowerPoint??

The existing solutions just don’t work for my case

Now, it’s not that they’re not good - most of them are pretty great and have some features that I would like to use if this were some once-off throwaway presentation, but since I would like to refer back to and manage the way I want they’re not really suitable

I also didn’t want to style everything from scratch or write lots of HTML everywhere

The Solution

Build it myself. Obviously

Instead of just looking at the existing options, I instead chose to build a library/framework that would work with my existing Astro site while keeping the implementation relatively minimal and just depending on plain CSS, Javascript, MDX, and Astro to get the job done

Use Existing Tooling

  1. HTML
  2. CSS
  3. Javascript (Typescript)
  4. MDX

Facilitated by - not coupled to - Astro

Code

So, since I’m building it myself, that means code - and that’s what we’re going to look at

The API

I wanted to keep the API relatively simple. It should work with existing markdown content and allow me to delineate a slide in a way that can be easily read from the DOM so as to minimize the amount of build-time processing I need to do as well as minimize how much markup I need to write. For this purpose, I decided that I want it to fit into an MDX document like so:

The API

1
Button to launch the presentation
2
3
<Presentation />
4
5
... Existing page content
6
7
<Slide>
8
# Heading for Slide
9
10
Some content for the slide
11
12
```js
13
console.log('I am a code block')
14
```
15
16
> And literally any other markdown content
17
18
</Slide>

Hiven the above, I had a high level idea that I would need two parts to this - firstly I want to use the Slide component to give me something to latch onto in the HTML that I can manipulate, secondly I know I would need some kind of component that would control the overall presentation state, I called that Presentation

The Slide Component

The Slide component is simply a wrapper that includes content in an HTML section with a class presentation-slide which will contain the contents of a slide

The Slide Component

components/Slide.astro

1
<section class="presentation-slide">
2
<slot />
3
</section>

The Presentation Component

The Presentation component needs to do a few different things:

The Presentation Component

  1. Hide presentation until enabled
  2. Allow navigation of slides
  3. Render slide content above existing page
  4. Manage transitions between slide pages

Firstly, we can just take a look at the HTML that we will render contains a few basic elements as well as a script tag that grabs a reference to these elements. The presentation-hidden class is used for hiding or showing the presentation when active/inactive:

Basic Elements

components/Presentation.astro

1
<button id="presentation-button" class="presentation-hidden" type="button">
2
Start Presentation
3
</button>
4
5
<div
6
id="presentation-container"
7
class="presentation-hidden presentation-overflow-hidden"
8
>
9
<main id="presentation-content">
10
<h1>No slides found on page</h1>
11
</main>
12
</div>
13
14
<script>
15
const button = document.getElementById('presentation-button') as HTMLButtonElement
16
const container = document.getElementById('presentation-container') as HTMLDivElement
17
const content = document.getElementById('presentation-content') as HTMLDivElement
18
</script>

Next, we can grab the actual slide content by using the presentation-slide class we defined earlier:

Get Slides

components/Presentation.astro

1
let slides = Array.from(document.querySelectorAll('.presentation-slide')).map(
2
(el) => el.outerHTML,
3
)
4
5
let slide = 0

Once we have the content of the slides and a variable to track which slide we are on, we can define a function that will set the slide content. This will set the innerHTML of the content element to the slide that is active. We can handle this by first defining some utilities for grabbing the next and previous slides as well as mapping a key code to the function that will resolve the next slide

Slide Utilities

components/Presentation.astro

1
const nextSlide = () => {
2
if (slide === slides.length - 1) {
3
return slide
4
}
5
6
return slide + 1
7
}
8
9
const prevSlide = () => {
10
if (slide === 0) {
11
return slide
12
}
13
14
return slide - 1
15
}
16
17
const keyHandlers: Record<string, () => number> = {
18
ArrowRight: nextSlide,
19
ArrowLeft: prevSlide,
20
}

Next, we can define what it means for us to start and end a presentation. For this example, starting a presentation will remove the presentation-hidden class from the main wrapper so we can make the presentation visible on the page as well as set the content to the current slide index (we initialized this to 0 above)

Start and End Presentation

components/Presentation.astro

1
const startPresentation = () => {
2
container.classList.remove('presentation-hidden')
3
if (slides.length) {
4
content.innerHTML = slides[slide]
5
}
6
}
7
8
const endPresentation = () => {
9
container.classList.add('presentation-hidden')
10
}

We set the content to slide instead of 0 so that we can pause and continue the presentation if we wanted to

Next, hook up some event handlers so that we can have a method for controlling our presentation:

Wiring things up

components/Presentation.astro

1
// If there is no presentation on the page then we don't initialize
2
if (slides.length) {
3
button.addEventListener('click', startPresentation)
4
5
window.addEventListener('keyup', (ev) => {
6
const isEscape = ev.key === 'Escape'
7
if (isEscape) {
8
endPresentation()
9
return
10
}
11
12
const getSlide = keyHandlers[ev.key]
13
14
if (!getSlide) {
15
return
16
}
17
18
const nextSlide = getSlide()
19
if (slide === nextSlide) {
20
return
21
}
22
23
slide = nextSlide
24
content.innerHTML = slides[slide]
25
})
26
}

In the above, the left and right arrows are used to navigate slides and the escape key is used to end the presentation

Next up, we need to add some CSS to make the slides pin to the root of our application above everything else so that you can actually use this:

Styling

components/Presentation.astro

1
<style is:global>
2
.presentation-overflow-hidden {
3
overflow: hidden;
4
}
5
6
.presentation-hidden {
7
display: none;
8
}
9
10
#presentation-container {
11
z-index: 10;
12
position: fixed;
13
top: 0;
14
left: 0;
15
right: 0;
16
bottom: 0;
17
overflow: auto;
18
19
backdrop-filter: blur(50px);
20
background-color: #0000007d;
21
}
22
23
#presentation-content {
24
display: flex;
25
flex-direction: column;
26
27
background-color: black;
28
color: white;
29
30
box-sizing: border-box;
31
min-height: 100vh;
32
width: 100%;
33
padding: 4rem;
34
}
35
</style>

And that’s pretty much it for the core implementation. One other piece of fanciness that I wanted to add was the ability to make an actual slide transition. To do this I decided to use the View Transitions API and found a few nice references on the Unecesssary View Transitions API List

In order to use this you need to have the feature enabled in your browser at the moment but it should be stable soon (I hope)

For this implementation, we will need to have different animations for the case where we are moving forwards or backwards. In order to do this, we will define some classes as part of our keyboard handler resolution that we will append to the presentation-container:

View Transitions

components/Presentation.astro

1
const nextClass = 'presentation-next'
2
const prevClass = 'presentation-prev'
3
4
const transitionClasses = [nextClass, prevClass]
5
6
const keyHandlers: Record<string, [string, () => number]> = {
7
ArrowRight: [nextClass, nextSlide],
8
ArrowLeft: [prevClass, prevSlide],
9
}

Then, we will update our event handling logic to set these classes on the contaienr

Setting the Classes

components/Presentation.astro

1
const [transitionClass, getSlide] = handler
2
3
content.classList.remove(...transitionClasses)
4
content.classList.add(transitionClass)
5
6
const nextSlide = getSlide()
7
if (slide === nextSlide) {
8
return
9
}

Then, instead just setting the content.innerHTML directly, we do it within the document.startViewTransition callback which will be what handles the state transition between the content leaving the DOM and the new content that is entering the DOM

Note that the startViewTransition API is experimental and typescript may complain, you will need to install @types/dom-view-transitions which will provide the type definition you need to use this API

Starting the Transition

components/Presentation.astro

1
document.startViewTransition(() => {
2
slide = nextSlide
3
content.innerHTML = slides[slide]
4
})

The last thing we need to do is define the view transitions for when the content enters and exists the DOM. The transitions are defined in the style tag of our component as follows:

Firstly we need to

Animations

components/Presentation.astro

1
@keyframes slide-out-right {
2
0% {
3
transform: translateX(0) scale(1);
4
}
5
15% {
6
transform: translateX(0) scale(0.8) translateY(0%);
7
}
8
85% {
9
transform: translateX(100%) scale(0.8) translateY(0%);
10
}
11
100% {
12
transform: translateX(100%) scale(1);
13
}
14
}
15
16
@keyframes slide-out-left {
17
0% {
18
transform: translateX(0) scale(1);
19
}
20
15% {
21
transform: translateX(0) scale(0.8) translateY(0%);
22
}
23
85% {
24
transform: translateX(-100%) scale(0.8) translateY(0%);
25
}
26
100% {
27
transform: translateX(-100%) scale(1);
28
}
29
}

The above defines two basic animations that we will use for our transitions. We define an animation that moves an element off the screen to the right called slide-out-right and another to move it to the left called slide-out-left. These animations can also be reversed to slide content in from the right or left respectively

For the “Next” animation we need to do the following:

  1. Slide the old content to the left
  2. Slide the new content from the right

In the below we set that the presentation-next class defines a view-transition-name called next. Then, we define the transitions for the old and new content that applies to the next transition name as an Animation. We are referencing a slide-out-right and slide-out-left animations which we defined previously:

CSS Transitions

Below is the transition for moving to the next slide

components/Presentation.astro

1
.presentation-next {
2
view-transition-name: next;
3
}
4
5
::view-transition-old(next) {
6
animation: slide-out-left 0.5s linear;
7
}
8
::view-transition-new(next) {
9
animation: slide-out-right 0.5s linear reverse;
10
}

view-transition-old refers to content that is being removed from the DOM, view-transition-new refers to the content that is being added to the DOM

Lastly, the implementation for the presentation-prev we can reuse the same animations for moving left or right as we defined previously, but change the directions as needed for the relevant section

components/Presentation.astro

1
.presentation-prev {
2
view-transition-name: prev;
3
}
4
5
::view-transition-old(prev) {
6
animation: slide-out-right 0.5s linear;
7
}
8
::view-transition-new(prev) {
9
animation: slide-out-left 0.5s linear reverse;
10
}

And yes, that’s a fair amount of code. All-in it’s about 200 lines - most of which is the CSS for the transition though. Generally the implementation is pretty straightforward and should be relatively easy to tweak to match the vibe of your website without adding any dependency bloat.

As it stands right now the implementation is pretty simple but leaves a lot of space to be extended

Added since this post was written

  • Presenter mode with some kind of synchronized state for multiple monitors (LocalStorage?)
  • Progress tracking
  • Support for non-static components

Future Ideas

  • Presenter notes and preview of next slide
  • Make this a library so other people can use it with less copy pasta
  • More transitions and styling possibilities
  • Dynamic code blocks/customizable transitions (Code Surfer)
  • Automatic zooming

Conclusion

We used some interesting CSS here and overall we can see that it’s not alays a huge amount of work to write your own implementation of something.

Additionally, for the sake of completeness - since this component is alive and ever changing within this website - you can view the current state of the code (sans commentary) below

Slide.astro
1
---
2
export interface Props {
3
centered?: boolean
4
highlight?: boolean
5
large?: boolean
6
}
7
8
const { centered, highlight, large} = Astro.props
9
---
10
11
<section class="presentation-slide" class:list={{ centered, highlight, large }}>
12
<slot />
13
</section>
Presentation.astro
1
<button id="presentation-button" class="presentation-hidden" type="button"
2
>Start Presentation</button
3
>
4
5
<div class="presentation-progress"></div>
6
7
<script>
8
import { createSyncReader, createSyncWriter } from "../sync";
9
10
const button = document.getElementById(
11
"presentation-button"
12
) as HTMLButtonElement;
13
14
let slides = Array.from(document.querySelectorAll(".presentation-slide"));
15
16
let slide = 0;
17
let presenter = false;
18
19
const presentationId = window.location.href;
20
const syncWriter = createSyncWriter<number>(presentationId);
21
22
const nextSlide = () => {
23
if (slide === slides.length - 1) {
24
return slide;
25
}
26
27
return slide + 1;
28
};
29
30
const prevSlide = () => {
31
if (slide === 0) {
32
return slide;
33
}
34
35
return slide - 1;
36
};
37
38
const nextClass = "presentation-next";
39
const currClass = "presentation-current";
40
const prevClass = "presentation-prev";
41
42
const transitionClasses = [nextClass, currClass, prevClass];
43
44
const keyHandlers: Record<string, () => number> = {
45
ArrowRight: nextSlide,
46
ArrowLeft: prevSlide,
47
};
48
49
const displaySlides = () => {
50
for (let i = 0; i < slides.length; i++) {
51
slides[i].classList.remove("active", "inactive", ...transitionClasses);
52
53
if (i === slide) {
54
slides[i].classList.add("active", currClass);
55
} else {
56
slides[i].classList.add("inactive");
57
58
if (i > slide) {
59
slides[i].classList.add(nextClass);
60
} else {
61
slides[i].classList.add(prevClass);
62
}
63
}
64
}
65
};
66
67
let presenting = false
68
const startPresentation = () => {
69
button.innerHTML = "Resume presentation";
70
document.body.classList.add("presentation-overflow-hidden");
71
72
presenting = true
73
displaySlides();
74
setProgress();
75
initListeners()
76
};
77
78
const endPresentation = () => {
79
document.body.classList.remove("presentation-overflow-hidden");
80
81
presenting = false
82
slides.map((s) =>
83
s.classList.remove("active", "inactive", ...transitionClasses)
84
);
85
};
86
87
const setPresenter = () => {
88
presenter = true;
89
document.body.classList.add("presentation-presenter")
90
syncWriter(slide);
91
};
92
93
const setProgress = () => {
94
const progress = ((slide+1)/slides.length)*100;
95
document.body.style.setProperty('--presentation-progress', `${progress}%`)
96
}
97
98
const transition = (nextSlide: number) => {
99
if (!presenting) {
100
return
101
}
102
103
if (slide === nextSlide) {
104
return;
105
}
106
107
slides.forEach((s) => s.classList.remove(...transitionClasses));
108
109
if (presenter) {
110
syncWriter(nextSlide);
111
}
112
113
slide = nextSlide;
114
115
displaySlides();
116
setProgress();
117
};
118
119
120
let listenersInitialized = false
121
const initListeners = () => {
122
if (listenersInitialized) {
123
return
124
}
125
126
listenersInitialized= true
127
window.addEventListener("keyup", (ev) => {
128
ev.preventDefault();
129
const isEscape = ev.key === "Escape";
130
if (isEscape) {
131
endPresentation();
132
return;
133
}
134
135
const isSpace = ev.key === " ";
136
if (isSpace) {
137
setPresenter();
138
return;
139
}
140
141
const getSlide = keyHandlers[ev.key];
142
143
if (!getSlide) {
144
return;
145
}
146
147
const nextSlide = getSlide();
148
transition(nextSlide);
149
});
150
151
let touchstartX = 0;
152
let touchendX = 0;
153
const handleGesure = () => {
154
const magnitude = Math.abs(touchstartX - touchendX);
155
156
if (magnitude < 40) {
157
// Ignore since this could be a scroll up/down
158
return;
159
}
160
161
if (touchendX < touchstartX) {
162
transition(nextSlide());
163
}
164
if (touchendX > touchstartX) {
165
transition(prevSlide());
166
}
167
};
168
169
document.addEventListener(
170
"touchstart",
171
(ev) => {
172
touchstartX = ev.changedTouches[0].screenX;
173
},
174
false
175
);
176
177
document.addEventListener(
178
"touchend",
179
(event) => {
180
touchendX = event.changedTouches[0].screenX;
181
handleGesure();
182
},
183
false
184
);
185
}
186
187
// If there is no presentation on the page then we don't initialize
188
if (slides.length) {
189
button.classList.remove("presentation-hidden");
190
button.addEventListener("click", startPresentation);
191
createSyncReader<number>(presentationId, slide, transition);
192
}
193
</script>
194
195
<style is:global>
196
.presentation-progress {
197
display: none;
198
}
199
200
.presentation-overflow-hidden {
201
overflow: hidden;
202
visibility: hidden;
203
204
.presentation-hidden {
205
display: none;
206
}
207
208
h1, h2, h3, h4 {
209
font-size: xx-large;
210
}
211
212
.presentation-slide.large {
213
font-size: x-large;
214
}
215
216
.presentation-progress {
217
transition: width 1000ms;
218
display: block;
219
visibility: visible;
220
position: absolute;
221
z-index: 20;
222
top:0px;
223
left: 0px;
224
width: var(--presentation-progress);
225
height: .25rem;
226
background: var(--color-brand-muted);
227
}
228
229
.presentation-slide {
230
position: fixed;
231
top: 0;
232
right: 0;
233
bottom: 0;
234
left: 0;
235
236
visibility: visible;
237
238
transition: transform 300ms ease-in-out;
239
240
display: flex;
241
flex-direction: column;
242
243
background-color: var(--color-base);
244
color: var(--color-on-base);
245
246
box-sizing: border-box;
247
min-height: 100vh;
248
width: 100%;
249
padding: 2rem 4rem;
250
251
z-index: 10;
252
overflow: auto;
253
254
&.centered {
255
flex: 1;
256
display: flex;
257
flex-direction: column;
258
align-items: center;
259
justify-content: center;
260
}
261
262
&.highlight{
263
background-color: var(--color-brand);
264
color: var(--color-on-brand)
265
}
266
267
.presentation-slide-only {
268
display: block;
269
}
270
271
.astro-code {
272
filter: none;
273
}
274
275
img {
276
max-height: 80vh;
277
}
278
279
}
280
281
&.presentation-presenter {
282
.presentation-slide {
283
border: none;
284
border-bottom: solid 8px var(--color-brand);
285
}
286
287
.presentation-note {
288
position: absolute;
289
bottom: 24px;
290
opacity: .8;
291
right: 24px;
292
left: 25%;
293
z-index: 999;
294
}
295
}
296
}
297
298
.presentation-slide-only {
299
display: none;
300
}
301
302
.presentation-next {
303
transform: translateX(100%);
304
}
305
306
.presentation-current {
307
transform: translateX(0%);
308
}
309
310
.presentation-prev {
311
transform: translateX(-100%);
312
}
313
314
.presentation-note {
315
display: none;
316
}
317
318
.presentation-presenter {
319
.presentation-slide {
320
border: dotted 8px var(--color-brand);
321
}
322
323
/* ensure that notes are visible if presentation mode is active, even if
324
not presenting */
325
.presentation-note {
326
display: block;
327
/* intentionally obnoxios color to draw attention */
328
background-color: crimson;
329
padding: 24px;
330
color: white;
331
font-size: xx-large;
332
}
333
}
334
</style>

References