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 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 return100 }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 = false120 const initListeners = () => {121 if (listenersInitialized) {122 return123 }124
125 listenersInitialized= true126 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/down157 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 false174 );175
176 document.addEventListener(177 "touchend",178 (event) => {179 touchendX = event.changedTouches[0].screenX;180 handleGesure();181 },182 false183 );184 }185
186 // If there is no presentation on the page then we don't initialize187 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 h1, h2, h3, h4 {208 font-size: xx-large;209 }210
211 .presentation-slide.large {212 font-size: x-large;213 }214
215 .presentation-progress {216 transition: width 1000ms;217 display: block;218 visibility: visible;219 position: absolute;220 z-index: 20;221 top:0px;222 left: 0px;223 width: var(--presentation-progress);224 height: .25rem;225 background: var(--color-brand-muted);226 }227
228 .presentation-slide {229 position: fixed;230 top: 0;231 right: 0;232 bottom: 0;233 left: 0;234
235 visibility: visible;236
237 transition: transform 300ms ease-in-out;238
239 display: flex;240 flex-direction: column;241
242 background-color: var(--color-base);243 color: var(--color-on-base);244
245 box-sizing: border-box;246 min-height: 100vh;247 width: 100%;248 padding: 2rem 4rem;249
250 z-index: 10;251 overflow: auto;252
253 &.centered {254 flex: 1;255 display: flex;256 flex-direction: column;257 align-items: center;258 justify-content: center;259 }260
261 &.highlight{262 background-color: var(--color-brand);263 color: var(--color-on-brand)264 }265
266 .presentation-slide-only {267 display: block;268 }269
270 .astro-code {271 filter: none;272 }273
274 }275
276
277 .presentation-presenter #presentation-content {278 border: solid 8px var(--color-brand);279 }280 }281
282 .presentation-slide-only {283 display: none;284 }285
286 .presentation-next {287 transform: translateX(100%);288 }289
290 .presentation-current {291 transform: translateX(0%);292 }293
294 .presentation-prev {295 transform: translateX(-100%);296 }297</style>