When Gatsby announced the first ever Silly Site Competition in November 2020, I was excited to create a suitably fun and silly project. My submission, “The Jabberwocky Poem,” was chosen as a top 5 runner-up. Here’s the story behind how it was made.
I knew first off that I wanted to create something simple and visually appealing. I was inspired by the idea of Star Wars-style scrolling text, and planned to drive that movement using the user’s scroll position. But what would be the right content?
From time to time it helps to have a well-read linguist at arm’s reach. My girlfriend Ana-Maria suggested Lewis Carroll’s famous Jabberwocky nonsense poem due to its complete and utter silliness. Perfect!
With the vision in mind and the content sourced, it was time for the fun stuff…
Creating the Animation
Prerequisite 1: 3D Transforms

Rather than scaling the text as the user scrolls, I wanted to translate the Z position inside a 3D container. It’s a subtle difference, but this gives the effect of the text actually moving towards you from a distance. To get this to work you add the following CSS properties to a container:
.text-container-motion { perspective: 100vw; transform-style: preserve-3d; perspective-origin: 50% 45%; ← Optional}
Prerequisite 2: GreenSock Animation Platform

The site relies on the GreenSock Animation Platform (GSAP), and in particular two of their amazing plugins: ScrollTrigger and SplitText. The aim of this article is not to provide a comprehensive guide to GSAP, but rather to show you how I implemented the library to achieve the end result.
Note: When using GSAP with Gatsby, you must test for the browser window before registering plugins. I included this code at the top of my index.tsx file.
if (typeof window !== `undefined`) { gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(SplitText) gsap.defaults({ overwrite: "auto", duration: 0.3 })}
The Title Transition with SplitText
The SplitText plugin wraps lines, words, and characters in nested div tags. When the page loads, SplitText works its magic and stores the div-wrapped characters in an object. The characters in that object (headingSplit) are then animated. Let me break that down for you:
const transitionTitleIn = () => { const headingSplit = new SplitText(".title-motion") // I use the gsap.set function to hide and randomly position the heading characters on the Y axis, between 160px and -160px. gsap.set(headingSplit.chars, { y: "random(160,-160,5)", opacity: 0 }) // Then set the title’s opacity to 1, because it is set to 0 in the CSS to prevent any undesired flashing when it’s first painted. gsap.set(".title-motion", { opacity: 1 }) // Now it’s time to bring the title into view! gsap.to(headingSplit.chars, { // I animate the Y position of each character back to 0 and the opacity to 1. y: 0, opacity: 1, delay: 0.6, // The stagger property with “random” gives it the silly and unpredictable effect. stagger: { from: "random", amount: 1.2 }, duration: 2, onComplete: () => { const scrollDownElement = document.querySelector(".scroll-down") scrollDownElement && scrollDownElement.classList.add("scroll-down-show") }, }) }
The Main Poem Timeline with ScrollTrigger
If at first you don’t succeed…
Part of any creative process is trial and error and this journey was no different. There were definitely some errors on the way to success! After establishing that the split text transition with random character appearance looked good, and the Z-translation was neat… I just needed to figure out how to optimize it.
In my first attempt I animated all of the poem’s sentences at the same time. The first sentence started right at the “front” and the final sentence was a mile away in the distance. I did this by looping through each sentence and incrementing its Z position by 100vh, until the 28th sentence was 2800vh back on the Z axis.
Yes, it was a mega silly timeline (and not silly in a good way). And it proved to be too slow for the browser to compute, even on a top spec MacbookPro — imagine what this would do to a normal computer. Worse, this approach made it very difficult to read the poem as there were no clear paragraph breaks, only a parade of sentences.
A More Performant Solution
The problem was that I was asking the browser to do too much, and asking it within a 3D space.
The ideal solution would be to only animate two blocks at a time. One paragraph that’s leaving (fading out towards you) and one that’s entering (fading in from the distance).
Like great artists, I went digging in the GSAP examples for inspiration. That’s when I came across some code that I could adapt.
Rather than creating one monolithic animation timeline with every paragraph and character transition nested inside it, I would create individual timelines and initiate them at the appropriate scroll position using ScrollTrigger.

I had a poem constant like so:
// The poem is an array of paragraph arrays, each containing sentences. This makes it easy for the content to be mapped.const POEM: string[][] = [ [ "'Twas brillig, and the slithy toves", "Did gyre and gimble in the wabe;", "All mimsy were the borogoves,", "And the mome raths outgrabe.", ], // --- [ '"Beware the Jabberwock, my son!', "The jaws that bite, the claws that catch!", "Beware the Jubjub bird, and shun", 'The frumious Bandersnatch!"', ], //...]
Mapped into suitable HTML elements…
{/* Animated */} {!disableMotion && ( <section className="text-container-motion"> {/* Title */} <div className="text title paragraph-motion"> <h1 className="title-motion">Jabberwocky</h1> <h2 className="title-motion"> from Through the Looking-Glass, and What Alice Found There (1871) </h2> <div className="scroll-down"> <img src={downIcon} alt="down" /> </div> </div> {/* Paragraphs - Poem Mapping */} {POEM.map((paragraph: string[], i: number) => ( <p key={`paragraph-${i}`} className="text paragraph-motion"> {paragraph.map((sentence, index) => ( <div key={`${sentence[0]}-${index}`}>{sentence}</div> ))} </p> ))} </section> )}
The text-container-motion is set to “position: absolute”, which causes the paragraphs to stack on top of each other.
When the page mounts (assuming animation is enabled) I call this function:
const setupWithAnimations = useCallback(() => { transitionTitleIn() // Clear any existing scroll triggers ScrollTrigger.getAll().forEach(trigger => trigger.kill()) // Grabs all of the paragraphs by their class name. const paragraphs: HTMLDivElement[] = gsap.utils.toArray(".paragraph-motion") // Ensures the content will remain fixed (necessary when switching between static and animated poems) gsap.set("main", { height: "100vh", position: "fixed" }) // Sets the body height based on the number of paragraphs // This enables scrolling even though the content is positioned fixed & absolute. gsap.set("body", { height: `${paragraphs.length * 100}vh` }) // Creates a ScrollTrigger for each paragraph section paragraphs.forEach((div, i) => { ScrollTrigger.create({ // Every 50vh the next trigger will start as the previous one ends. start: () => (i - 0.5) * innerHeight, end: () => (i + 0.5) * innerHeight, // When activated, call the function to handle transitions onToggle: () => setActiveParagraph(div), }) }) setupBackground(true) }, [])
When the scroll position reaches a certain trigger point, ‘setActiveParagraph’ will be called to animate the 2 paragraphs.
const currentParagraph = useRef<HTMLDivElement>() const setActiveParagraph = useCallback((newParagraph: HTMLDivElement) => { const transitionIn = (paragraph: HTMLDivElement) => { // Sets the paragraph back on the z axis gsap.set(paragraph, { z: -1000, autoAlpha: 0, //Hidden in CSS for initial mount }) // Gets the sentences and splits them. const sentences: HTMLParagraphElement[] = gsap.utils.toArray( paragraph.querySelectorAll("p") ) const sentenceSplits: SplitText[] = sentences.map( (sentence: HTMLParagraphElement) => new SplitText(sentence) ) const timeline = gsap.timeline({ paused: true }) // Schedule the sentences appearing sentenceSplits.forEach((splitSentence, i) => { timeline.fromTo( splitSentence.chars, { y: "random(72,-72)", opacity: 0, }, { y: 0, delay: i, // incremental delay for each sentence stagger: { from: "random", amount: 0.4 }, duration: 0.8, opacity: 1, ease: "ease-in", } ) }) // Get the container to appear timeline.to( paragraph, { z: 0, autoAlpha: 1, duration: sentenceSplits.length }, 0 // make it happen at the same time as the sentences ) timeline.call(() => timeline.kill()) // Run the animation timeline.play() } const transitionOut = (oldParagraph: HTMLDivElement) => { gsap.to(oldParagraph, { z: 200, autoAlpha: 0, duration: 1 }) } if (newParagraph !== currentParagraph.current) { transitionOut(currentParagraph.current) transitionIn(newParagraph) currentParagraph.current = newParagraph } }, [])
Accessibility: Implementing “prefers reduced motion”
The competition stipulated that accessibility and performance were key factors in the judging process. And rightly so.
My website was heavily animated and this could cause a problem for acessibility devices, not to mention for those prone to motion sickness.
Therefore I created a static version of the poem and switched between the two by toggling a ‘disableMotion’ state variable.
When the page loads, I check for the media query ‘prefers-reduced-motion’, and if it’s true I set ‘disableMotion’ to true, otherwise it remains false.
I also added a disable / enable motion button which allows the user to manually set the state.
useEffect(() => { // Grab the prefers reduced media query const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") // Check if the media query matches or is not available. if (!mediaQuery || mediaQuery.matches) { console.log("User prefers reduced motion") setDisableMotion(true) } else { console.log("User has no motion preference") } // Adds an event listener to check for changes in the media query's value. mediaQuery.addEventListener("change", () => { if (mediaQuery.matches) { setDisableMotion(true) } else { setDisableMotion(false) } }) }, [])
When the ‘disableMotion’ state changes (and when the page mounts), I call the relevant setup function.
// Handle motion toggle - either by button press or media query change useLayoutEffect(() => { if (disableMotion) { setupWithoutAnimations() } else { setupWithAnimations() } window.scrollTo(0, 0) }, [disableMotion])
Once the difficult stuff was out the way, I experimented with a variety of different colour palettes and ultimately settled on this one:

To further increase performance, I used Gatsby Image for the background image and the photo of Lewis Carroll.
You can visit the code repository here if you’d like to take a closer look.
If you have any questions or would like to collaborate with me on something silly or sensible, you can reach me at matthew@loopspeed.co.uk
Here’s my tired-looking mug with a much needed cup of coffee in my super swanky Silly Site Challenge prizewinners mug. LOL. Cheers!

Stay silly!
Matt