Something not making sense? For help or questions, ping @mikestilling in Slack.
Working with code removes the concept of frames. Here, we can build seamless experiences that capture transitions between every interaction. Below, I'll cover the basics of how.
This is a big one. Before diving into some fairly complex code, let's run through the foundations of interactivity on the web. Here's what we'll cover:
Interaction design comes down to giving users something to interact with that responds to their actions. Standard patterns include on click, hover, or while hovering—but they can get much more complex. For example, an LLM chat experience that changes its system prompt based on the LLM's analysis of user input...
For common interaction patterns, like a simple button or link hover, we can use plain ol' CSS. In CSS, we apply styles for interactive states using a special selector, like :hover. To show a simple code example of this in plain CSS, here's how we could target an HTML element with the class of button:
/* Normal state of button */
.button {
background-color: green; /* background color of button */
color: white; /* text color of button */
border-radius: 6px; /* rounded corner radius */
transition: all 200ms ease-out 0ms; /* how the animation should occur between the two states */
}
/* hover state of button */
.button:hover {
background-color: blue;
}In the above code, the button's background color would change from green to blue on hover. The transition property sets the animation to occur over 200 milliseconds using an ease-out curve for easing.
While the above shows how to animate states in plain CSS, using a dedicated <style> tag within an HTML file or separate .css file—we've been using Tailwind. To do something like this in HTML, using Tailwind, we'd do something like this:
<a href="google.com" class="bg-[green] text-[white] rounded-6 transition-all duration-200 ease-out hover:bg-blue">
Button text
</a>Hopefully, the above also helps correlate how Tailwind shakes out in actual CSS, too. Now, we just covered hover, but there are a load of states we can account for in CSS.
Specifically, HTML input form elements like a checkbox or radio can provide some useful selectors we can animate between using CSS. Don't automatically assume form elements mean boring, either.
For example, a set of radios could be styled to look like tabs or cards. Below, is a quick demo using purely HTML/CSS to create tabs. Click the tabs to transition the active indicator.
Just like we selected an element's :hover state prior, we can do things based on an elements :checked state (if it supports a checked state). Or even when one element :has() a specific element that is :checked 🤯.
Let's say we wanted to give users the ability to edit a bit of text in our design. HTML provides the easiest way to do this. Code-wise, all we need to do is apply contenteditable to the opening tag of the element. Like this:
<p contenteditable class="text-24/110">
Edit and retype this headline by clicking on it
</p>Here's a working demo using purely HTML/CSS, click the text to see:
Once contenteditable is applied to the opening tag of an HTML element, that element now will receive focus. Just like :hover, we can target :focus or :focus-visible in CSS—and apply fancy styles, transitions, and the works.
To jump right into the next example; maybe we need some expandable FAQ dropdowns. The HTML <details> element does just that. It requires a <summary> element within it to work, though.
Here are the docs and a code reference:
<details>
<summary>
Where the collapsed content goes
</summary>
Where the expanded content goes
</details>While I'm not going to account for transitioning or animating between states for this element, here's a quick demo of how something like this could work:
The stuff above covers interactions, but what about animating stuff with CSS? For that, we can utilize CSS's @keyframe rules (docs).
CSS keyframes allow us to set properties on an element in stages/or steps. Take for example the code below:
/* Utility class that can be added to HTML elements */
.animate-spin-clockwise {
animation: spin-clockwise 10s infinite;
}
/* CSS keyframe animation */
@keyframes spin-clockwise {
0% {
transform: rotate3d(0,0,1,0deg);
}
100% {
transform: rotate3d(0,0,1,360deg);
}
}This animation will rotate an element clockwise by transitioning the transform: rotate3d() property on whichever element it is applied to. The above only has two steps, one at 0% (start of animation) and one at 100% (end of animation.). However, you could add in styles at various points with varying styles (i.e. 23% or 60%).
To show how this looks, let's apply it to an element with a CSS conic gradient background that's also blurred, using CSS's filter property.
To reel this in, it's unlikely you'll remember all of this. However, roughly understanding where and how to handle certain types of interaction and animation (HTML/CSS vs. JavaScript) should come in handy down the line. If you thought this was complex, hold my beer, we're about to dive into some JavaScript...
There are infinite ways to make things interactive with JavaScript. I'll cover the most common patterns I use.
Similar to CSS, JavaScript also targets elements and applies behavior to them, just with different syntax. The most common way to target elements on a page is .querySelector() or .querySelectorAll() (docs).
To demonstrate this, let's start with a little bit of HTML. For demo purposes, let's say we had a headline that we wanted to change the text of when a user clicks it:
<h1 data-headline class="text-48/100">
Click this headline to change the text
</h1>In the above, you'll notice a new concept: data attributes. Data attributes are an arbitrary way to store extra information on an HTML element. They're useful for keeping CSS selectors separate from JavaScript selectors. If we strictly use data attributes for JS, other developers will know an element has JS behavior attached to it.
To select this element in JavaScript, we'd do something like this:
document.querySelector('[data-headline]');This essentially searches our entire HTML document for the first element with the data attribute of data-headline. Say we had multiple elements on our page that we wanted to apply the same interaction to. If we applied data-headline to all of these elements, we could simply .querySelectorAll them like:
document.querySelectorAll('[data-headline]');After selecting elements, we need to decide when to do something with them. For that, we add a JavaScript event listener. Event listeners watch for specific events (like click) and let us write code that responds to them.
Here's how this works in code for the two selectors above:
// Create a variable alias for the element we're selecting
const headline = document.querySelector('[data-headline]');
// Do something when our selected element is clicked
headline.addEventListener('click', () => {
// Write scripts that fire when headline is clicked
});
// Example for multiple elements
const headlines = document.querySelectorAll('[data-headline]');
// For each of the selected headlines
headlines.forEach((element) => {
// Do something when one is clicked
element.addEventListener('click', () => {
// Write scripts that fire when headline is clicked
})
});Now that we can select elements and respond to events, all that's left is writing the code defining what happens when the event is fired.
So far, we've used vanilla JavaScript (pure JS, no frameworks). Just like Tailwind makes CSS easier, libraries make JavaScript easier. For animation, GSAP or Motion are popular choices. In our codebase, GSAP is already integrated.
To show how GSAP works, we'll use a simple tween and the GSAP Text Plugin to animate our headline text:
<!-- HTML -->
<h1 data-headline class="text-48/100">
Click this headline to change the text
</h1>
<!-- JavaScript -->
<script>
// Create a variable alias for the element we're selecting
const headline = document.querySelector('[data-headline]');
// Do something when our selected element is clicked
headline.addEventListener('click', () => {
// Create a gsap tween
gsap.to(
// Select the headline
headline, {
// GSAP text plugin: retypes headline using the text specified below
text: 'This text will replace the headline',
// The duration of the animation (1 second)
duration: 1,
// The easing curve of the animation
ease: "power3.inOut"
}
);
});
</script>To visualize how this code works, click on the headline in the demo below:
Again, not super important to fully comprehend everything here. The key takeaway: try HTML first, then CSS, and only reach for JavaScript when necessary. Simpler code is easier to maintain and less likely to get janky.
Now let's put this knowledge to work and actually build something. Since HTML/CSS animation is fairly straightforward, we'll dive straight into JavaScript.
To keep it relatively simple outright, we'll animate the section we created's content in when the page loads. To do this we'll use a GSAP timeline. After we do that, we'll vibe code a WebGL shader effect as a background to our visual in the section.
We'll work on the page we created in the last lesson. Open it up.
Before we start scripting our animation, we'll need to hide the elements we want to animate on our page and set their initial states (since we'll be animating them in to view). However, since some users may not have JavaScript enabled, we want our page's content to be visible in the scenario we cannot animate it in with JavaScript.
We can handle this with CSS. By default, our content will be hidden when the page loads (so we can animate it in), but if a user doesn't have JavaScript enabled, it's visible.
To do this, we'll apply a couple of classes to our <helm-section>'s opening tag:
<helm-section class="invisible noscript:visible">
<!-- Our section's content -->
</helm-section>Now that we have that in place, we can start writing scripts. First, we'll want to set the initial state of the elements that we plan to animate in. This state is the position the elements will animate from.
Below the <helm-footer></helm-footer> element on our page, we'll add in a <script></script> tag to write our JavaScript within. Then, we'll click between the opening and closing tag and hit the enter key three times.
Inside of the script tag, we can scaffold a function that runs once the page load event is fired. Similar to detecting hovers or clicks, we can watch for system events like this using event listeners. Here's the code:
function animateLoad() {};
document.addEventListener('DOMContentLoaded', animateLoad);After this, we'll add some data attributes to our HTML that we'll use to target the elements we want to animate in JavaScript:
<helm-section data-hero class="invisible noscript:visible">
<helm-container class="grid grid-cols-2 gap-16 items-center py-96 px-16 border-x border-edge">
<!-- Column 1: Text -->
<div class="text-balance">
<h1 data-headline class="text-32/110 tracking-[-0.02em] mb-16">
Here's our sections headline using a headline one tag
</h1>
<p data-description class="text-18 text-neutral-600">
This is a description below the section headline. Naturally, this P tag will stack vertically below the headline.
</p>
</div>
<!-- Column 2: Visual -->
<div data-visual class="relative flex aspect-16/9 overflow-hidden bg-neutral-25 border border-edge rounded-6">
<img src="/assets/images/util/triplo.png" width="1818" height="1020" class="relative w-full h-auto" alt=""/>
</div>
</helm-container>
</helm-section>With our data attributes in place, within the function, we'll target the section, select elements within it, and use GSAP's SplitText plugin to break apart our headline and description into words and lines of words we can animate:
function animateLoad() {
// Select elements and split text
const section = document.querySelector('[data-hero]');
const splitHeadline = SplitText.create(section.querySelector('[data-headline]'), {type: 'words'});
const splitDescription = SplitText.create(section.querySelector('[data-description]'), {type: 'lines'});
};At this point, we can create a GSAP timeline. If you've used AfterEffects, GSAP timelines are kinda like the timeline editor in there. Rather than setting keyframes visually, we'll set them programmatically.
function animateLoad() {
// Select elements and split text
const section = document.querySelector('[data-hero]');
const splitHeadline = SplitText.create(section.querySelector('[data-headline]'), {type: 'words'});
const splitDescription = SplitText.create(section.querySelector('[data-description]'), {type: 'lines'});
// Create a GSAP timelines
const tl = gsap.timeline();
tl
// Set the initial states of the elements
.set(splitHeadline.words, {y:24, autoAlpha:0, filter: 'blur(10px)'})
.set(splitDescription.lines, {y:24, autoAlpha:0, filter: 'blur(10px)'})
.set(section.querySelector('[data-visual]'), {autoAlpha:0, y:48, filter: 'blur(10px)'})
.set(section, {autoAlpha:1})
// Animate the elements to this state
.to(splitHeadline.words, {y:0, autoAlpha:1, filter: 'blur(0px)', duration: 1, stagger: 0.02, ease: "power3.out"})
.to(splitDescription.lines, {y:0, autoAlpha:1, filter: 'blur(0px)', duration: 1, stagger:0.1, ease: "power3.out"}, '-=1')
.to(section.querySelector('[data-visual]'), {autoAlpha:1, y:0, filter: 'blur(0px)', duration: 1, ease: "power3.out"}, "-=1")
};With all of this in place, your sections content should animate in when you refresh the page. The full code for the page should look like this:
---
layout: "main.webc"
title: "First page"
seoTitle: "Stripe | My first page"
ogTitle: "Stripe | My first page"
seoDesc: "This is the first new page I made in my codebase."
ogDesc: "This is the first new page I made in my codebase."
ogImage: "/assets/images/og/default.jpg"
ogImageAlt: ""
changefreq: "yearly"
---
<helm-nav></helm-nav>
<main>
<helm-section data-hero class="invisible noscript:visible">
<helm-container class="grid grid-cols-2 gap-16 items-center py-96 px-16 border-x border-edge">
<!-- Column 1: Text -->
<div class="text-balance">
<h1 data-headline class="text-32/110 tracking-[-0.02em] mb-16">
Here's our sections headline using a headline one tag
</h1>
<p data-description class="text-18 text-neutral-600">
This is a description below the section headline. Naturally, this P tag will stack vertically below the headline.
</p>
</div>
<!-- Column 2: Visual -->
<div data-visual class="relative flex aspect-16/9 overflow-hidden bg-neutral-25 border border-edge rounded-6">
<img src="/assets/images/util/triplo.png" width="1818" height="1020" class="relative w-full h-auto" alt=""/>
</div>
</helm-container>
</helm-section>
</main>
<helm-footer></helm-footer>
<script>
function animateLoad() {
// Select elements and split text
const section = document.querySelector('[data-hero]');
const splitHeadline = SplitText.create(section.querySelector('[data-headline]'), {type: 'words'});
const splitDescription = SplitText.create(section.querySelector('[data-description]'), {type: 'lines'});
// Create a GSAP timelines
const tl = gsap.timeline();
tl
// Set the initial states of the elements
.set(splitHeadline.words, {y:24, autoAlpha:0, filter: 'blur(10px)'})
.set(splitDescription.lines, {y:24, autoAlpha:0, filter: 'blur(10px)'})
.set(section.querySelector('[data-visual]'), {autoAlpha:0, y:48, filter: 'blur(10px)'})
.set(section, {autoAlpha:1})
// Animate the elements to this state
.to(splitHeadline.words, {y:0, autoAlpha:1, filter: 'blur(0px)', duration: 1, stagger: 0.02, ease: "power3.out"})
.to(splitDescription.lines, {y:0, autoAlpha:1, filter: 'blur(0px)', duration: 1, stagger:0.1, ease: "power3.out"}, '-=1')
.to(section.querySelector('[data-visual]'), {autoAlpha:1, y:0, filter: 'blur(0px)', duration: 1, ease: "power3.out"}, "-=1")
};
document.addEventListener('DOMContentLoaded', animateLoad);
</script>Visually the page should look like this:
Yet again, if this doesn't make complete sense, that's okay. As long as you get the gist, you can use AI to create this sort stuff. Let's move on to some gnarly WebGL stuff...
Warning: I barely know the first thing about WebGL. Though, I know enough to be dangerous with AI assistance. We'll cover some of the disclaimers and gotchas of AI in the next lesson. But for now, let's dive in.
We'll add a WebGL background effect behind the image we added a couple lessons back. I don't know much about WebGL, but I do know it renders in an HTML <canvas> element. Rather than letting AI decide where our WebGL goes, let's stub out a <canvas> element behind the existing image in our code:
<helm-section data-hero class="invisible noscript:visible">
<helm-container class="grid grid-cols-2 gap-16 items-center py-96 px-16 border-x border-edge">
<!-- Column 1: Text -->
<div class="text-balance">
<h1 data-headline class="text-32/110 tracking-[-0.02em] mb-16">
Here's our sections headline using a headline one tag
</h1>
<p data-description class="text-18 text-neutral-600">
This is a description below the section headline. Naturally, this P tag will stack vertically below the headline.
</p>
</div>
<!-- Column 2: Visual -->
<div data-visual class="relative flex aspect-16/9 overflow-hidden bg-neutral-25 border border-edge rounded-6">
<canvas data-webgl-effect class="absolute top-0 left-0 w-full h-full"></canvas>
<img src="/assets/images/util/triplo.png" width="1818" height="1020" class="relative w-full h-auto" alt=""/>
</div>
</helm-container>
</helm-section>WebGL can be written in JavaScript. Similar to stubbing out where it goes in our UI, let's add a comment in our JavaScript to declare where we'd like the LLM to place the scripts for this effect:
<script>
function animateLoad() {
// Select elements and split text
const section = document.querySelector('[data-hero]');
const splitHeadline = SplitText.create(section.querySelector('[data-headline]'), {type: 'words'});
const splitDescription = SplitText.create(section.querySelector('[data-description]'), {type: 'lines'});
// Create a GSAP timelines
const tl = gsap.timeline();
tl
// Set the initial states of the elements
.set(splitHeadline.words, {y:24, autoAlpha:0, filter: 'blur(10px)'})
.set(splitDescription.lines, {y:24, autoAlpha:0, filter: 'blur(10px)'})
.set(section.querySelector('[data-visual]'), {autoAlpha:0, y:48, filter: 'blur(10px)'})
.set(section, {autoAlpha:1})
// Animate the elements to this state
.to(splitHeadline.words, {y:0, autoAlpha:1, filter: 'blur(0px)', duration: 1, stagger: 0.02, ease: "power3.out"})
.to(splitDescription.lines, {y:0, autoAlpha:1, filter: 'blur(0px)', duration: 1, stagger:0.1, ease: "power3.out"}, '-=1')
.to(section.querySelector('[data-visual]'), {autoAlpha:1, y:0, filter: 'blur(0px)', duration: 1, ease: "power3.out"}, "-=1")
};
document.addEventListener('DOMContentLoaded', animateLoad);
// Add WebGL scripts here
</script>Now, we start to vibe. To keep things a less complex, in our prompt, let's tell Cursor to not use any new libraries or dependencies and only utilize vanilla JavaScript.
Here's my prompt:
Since AI workflows are non-deterministic, it's likely we all got slightly different results from this. After Cursor writes this code, you should be able to save and refresh your webpage to see the results.
At this point, you can further prompt to refine the effect or review and accept the changes to your code. Once you're happy with it, hop over to GitHub desktop and commit the changes and push your code so that it's synced to your website.
If you've made it this far, I'm impressed. As a person who has never written an online course before, I'm sure this first attempt hasn't been the easiest to work through on the user side. I'd love to see what you created—please share a link to your site with me (@mikestilling)!
Finally, since we've gotten through all the basics, Lesson 5 – Do more with AI will go full-send into AI and vibe coding. Last time, click the lesson below to keep going. 🙂