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
}
6
7
const { centered, highlight } = Astro.props
8
---
9
10
<section class="presentation-slide" class:list={{ centered, highlight }}>
11
<slot />
12
</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
syncWriter(slide);
90
};
91
92
const setProgress = () => {
93
const progress = ((slide+1)/slides.length)*100;
94
document.body.style.setProperty('--presentation-progress', `${progress}%`)
95
}
96
97
const transition = (nextSlide: number) => {
98
if (!presenting) {
99
return
100
}
101
102
if (slide === nextSlide) {
103
return;
104
}
105
106
slides.forEach((s) => s.classList.remove(...transitionClasses));
107
108
if (presenter) {
109
syncWriter(nextSlide);
110
}
111
112
slide = nextSlide;
113
114
displaySlides();
115
setProgress();
116
};
117
118
119
let listenersInitialized = false
120
const initListeners = () => {
121
if (listenersInitialized) {
122
return
123
}
124
125
listenersInitialized= true
126
window.addEventListener("keyup", (ev) => {
127
ev.preventDefault();
128
const isEscape = ev.key === "Escape";
129
if (isEscape) {
130
endPresentation();
131
return;
132
}
133
134
const isSpace = ev.key === " ";
135
if (isSpace) {
136
setPresenter();
137
return;
138
}
139
140
const getSlide = keyHandlers[ev.key];
141
142
if (!getSlide) {
143
return;
144
}
145
146
const nextSlide = getSlide();
147
transition(nextSlide);
148
});
149
150
let touchstartX = 0;
151
let touchendX = 0;
152
const handleGesure = () => {
153
const magnitude = Math.abs(touchstartX - touchendX);
154
155
if (magnitude < 40) {
156
// Ignore since this could be a scroll up/down
157
return;
158
}
159
160
if (touchendX < touchstartX) {
161
transition(nextSlide());
162
}
163
if (touchendX > touchstartX) {
164
transition(prevSlide());
165
}
166
};
167
168
document.addEventListener(
169
"touchstart",
170
(ev) => {
171
touchstartX = ev.changedTouches[0].screenX;
172
},
173
false
174
);
175
176
document.addEventListener(
177
"touchend",
178
(event) => {
179
touchendX = event.changedTouches[0].screenX;
180
handleGesure();
181
},
182
false
183
);
184
}
185
186
// If there is no presentation on the page then we don't initialize
187
if (slides.length) {
188
button.classList.remove("presentation-hidden");
189
button.addEventListener("click", startPresentation);
190
createSyncReader<number>(presentationId, slide, transition);
191
}
192
</script>
193
194
<style is:global>
195
.presentation-progress {
196
display: none;
197
}
198
199
.presentation-overflow-hidden {
200
overflow: hidden;
201
visibility: hidden;
202
203
.presentation-hidden {
204
display: none;
205
}
206
207
.presentation-progress {
208
transition: width 1000ms;
209
display: block;
210
visibility: visible;
211
position: absolute;
212
z-index: 20;
213
top:0px;
214
left: 0px;
215
width: var(--presentation-progress);
216
height: .25rem;
217
background: var(--color-brand-muted);
218
}
219
220
.presentation-slide {
221
position: fixed;
222
top: 0;
223
right: 0;
224
bottom: 0;
225
left: 0;
226
227
visibility: visible;
228
229
transition: transform 300ms ease-in-out;
230
231
display: flex;
232
flex-direction: column;
233
234
background-color: var(--color-base);
235
color: var(--color-on-base);
236
237
box-sizing: border-box;
238
min-height: 100vh;
239
width: 100%;
240
padding: 2rem 4rem;
241
242
z-index: 10;
243
overflow: auto;
244
245
&.centered {
246
flex: 1;
247
display: flex;
248
flex-direction: column;
249
align-items: center;
250
justify-content: center;
251
}
252
253
&.highlight{
254
background-color: var(--color-brand);
255
color: var(--color-on-brand)
256
}
257
258
.presentation-slide-only {
259
display: block;
260
}
261
262
.astro-code {
263
filter: none;
264
}
265
266
}
267
268
269
.presentation-presenter #presentation-content {
270
border: solid 8px var(--color-brand);
271
}
272
}
273
274
.presentation-slide-only {
275
display: none;
276
}
277
278
.presentation-next {
279
transform: translateX(100%);
280
}
281
282
.presentation-current {
283
transform: translateX(0%);
284
}
285
286
.presentation-prev {
287
transform: translateX(-100%);
288
}
289
</style>

References