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:
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:
So I investigated a few solutions:
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
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
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:
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 Presentation
Component
The Presentation
component needs to do a few different things:
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:
Next, we can grab the actual slide content by using the presentation-slide
class we defined earlier:
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
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)
Next, hook up some event handlers so that we can have a method for controlling our presentation:
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:
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
:
Then, we will update our event handling logic to set these classes on the contaienr
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
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
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
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:
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
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
1---2export interface Props {3 centered?: boolean4 highlight?: boolean5 large?: boolean6}7
8const { centered, highlight, large} = Astro.props9---10
11<section class="presentation-slide" class:list={{ centered, highlight, large }}>12 <slot />13</section>
1<button id="presentation-button" class="presentation-hidden" type="button"2 >Start Presentation</button3>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 = false68 const startPresentation = () => {69 button.innerHTML = "Resume presentation";70 document.body.classList.add("presentation-overflow-hidden");71
72 presenting = true73 displaySlides();74 setProgress();75 initListeners()76 };77
78 const endPresentation = () => {79 document.body.classList.remove("presentation-overflow-hidden");80
81 presenting = false82 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 return101 }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 = false121 const initListeners = () => {122 if (listenersInitialized) {123 return124 }125
126 listenersInitialized= true127 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/down158 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 false175 );176
177 document.addEventListener(178 "touchend",179 (event) => {180 touchendX = event.changedTouches[0].screenX;181 handleGesure();182 },183 false184 );185 }186
187 // If there is no presentation on the page then we don't initialize188 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 if324 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>