Emil KowalskiDesign Engineer

I recently launched the early version of Animations on the web, a course about web animations. I thought it could be interesting to write about how I built the platform for it, talk about the design process, tech stack, and more.

Why a custom platform?

I want to build things that I can be proud of. While there are a few course creation platforms out there, I wanted something that feels like me and something that I can customize to my needs, so that the students can get the best experience possible.

The design process

Most of the things I build are monochrome, but given that this is my first paid product, I wanted to give it more personality. People often call good animations 'buttery smooth,' so I thought it would be a good idea to use a buttery yellow as the accent color. Based on that, we also worked on a melting butter logo with Nev Flynn.

I'm a big fan of courses myself. I already knew that I wanted my platform to look like a mix of Josh Comeau's courses and Sam Selikoff's buildui.com. Mariana helped me design the course, it wouldn't have looked the same without her help.

 
We've chosen Inter as the sans font as it's readable. The mono font is called Commit Mono; I absolutely love it. I'm using it for this site and in VSCode as well.

 
Each lesson features a video on top, followed by a text version of the video with interactive components and additional explanations. The navigation is right next to the text; it's sticky, enabling users to easily jump to the next lesson

Animations on the web lesson page.

The colors come from Radix; I'm using the sand color scale. Radix Colors are my go-to for almost all of my projects. They have light and dark mode variants and describe the use case for each color.

 
I also worked on some illustrations together with Nev Flynn to make it look more interesting and add a bit of uniqueness to it. Each illustration illustrates a different part of the course.

What makes an animation feel right?

Tech stack

The platform is a Next.js application.

 
Authentication and storing user data is handled through Supabase. I plan to migrate the authentication to Clerk once a cool project we're working on launches.

 
Each lesson is an mdx file. I style the app using tailwindcss. To help keep my classes readable, I use tailwind's prettier plugin.

Authentication

Users can log in using a magic link. I use lemonsqueezy for payments. Their API lets me check whether the entered email is a paying customer.

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const email = searchParams.get("email");
 
  const res = await fetch(
    `https://api.lemonsqueezy.com/v1/customers?filter[email]=${email}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.LEMON_SQUEEZY_KEY}`,
        Accept: "application/json",
      },
    }
  );
 
  const { data: foundCustomers } = await res.json();
 
  // ...
}
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const email = searchParams.get("email");
 
  const res = await fetch(
    `https://api.lemonsqueezy.com/v1/customers?filter[email]=${email}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.LEMON_SQUEEZY_KEY}`,
        Accept: "application/json",
      },
    }
  );
 
  const { data: foundCustomers } = await res.json();
 
  // ...
}

We then generate a magic link that gets passed to the sendEmail function, which sends an email using loops. I decided to generate the link myself, because I wanted to style my emails rather than use the defaults from Supabase when the signInWithOtp function is used used.

const { data, error } = await admin.auth.admin.generateLink({
  email: email,
  type: "magiclink",
});
const { data, error } = await admin.auth.admin.generateLink({
  email: email,
  type: "magiclink",
});

MDX

I decided to go with MDX as it allows me to include my own React components. That way, I can help explain some concepts by providing interactive examples. The example below is used in the course to help students understand how each spring property affects an animation.

stiffness
damping
mass

This visualizer is heavily inspired by Morses's work.

I wanted to keep this platform very simple and limit the number of external dependencies, so I went with the most basic MDX setup possible, which is described here.

 
You can see how I structure my files below.

---
title: "What makes an animation feel right?"
description: "Why some animations feel better than others."
video: "https://player.vimeo.com/video/secret-vimeo-id"
resources:
  [
    {
      name: "Designing Fluid Interfaces",
      url: "https://developer.apple.com/videos/play/wwdc2018/803",
    },
    {
      name: "Invisible Details of Interaction Design",
      url: "https://rauno.me/craft/interaction-design",
    },
  ]
---
 
Some course content here about spring animations.
 
## A heading
 
An interactive component below to help understand the concept better.
 
<SpringVisualizer />
---
title: "What makes an animation feel right?"
description: "Why some animations feel better than others."
video: "https://player.vimeo.com/video/secret-vimeo-id"
resources:
  [
    {
      name: "Designing Fluid Interfaces",
      url: "https://developer.apple.com/videos/play/wwdc2018/803",
    },
    {
      name: "Invisible Details of Interaction Design",
      url: "https://rauno.me/craft/interaction-design",
    },
  ]
---
 
Some course content here about spring animations.
 
## A heading
 
An interactive component below to help understand the concept better.
 
<SpringVisualizer />

Syntax highlighting is handled through rehype-pretty-code. I'm using the GitHub theme for light and dark mode.

Tracking progress

I track the progress of each lesson for each user, meaning that on the main platform page, you'll see a progress bar for each unfinished video and a 'watched' label for videos you've finished watching.

Animations on the web main page showing the progress for each lesson.

I know, these rectangles don't look great. I'm working on some illustrations for the thumbnails.

I'm using react-player to play the videos. It exposes an onProgress prop, which makes it incredibly easy to update the progress in the database.

async function onProgress(state: OnProgressProps) {
  const { error } = await supabase.from("video_progress").upsert(
    {
      auth_uid: user.id
      video_id: video.id,
      progress: state.played.toFixed(2),
      watched_at: new Date().toISOString(),
    },
    {
      onConflict: "auth_uid, video_id",
    }
  );
}
 
// ...
 
<ReactPlayer
  url={video}
  height="100%"
  width="100%"
  controls
  onProgress={onProgress}
/>
async function onProgress(state: OnProgressProps) {
  const { error } = await supabase.from("video_progress").upsert(
    {
      auth_uid: user.id
      video_id: video.id,
      progress: state.played.toFixed(2),
      watched_at: new Date().toISOString(),
    },
    {
      onConflict: "auth_uid, video_id",
    }
  );
}
 
// ...
 
<ReactPlayer
  url={video}
  height="100%"
  width="100%"
  controls
  onProgress={onProgress}
/>

Additionally, there's a "Continue watching" section which shows the video you've watched most recently that you haven't finished yet. That's just a simple Supabase query in a server component.

const { data } = await supabase
  .from("video_progress")
  .select("video_id, watched_at")
  .eq("auth_uid", user.data.user?.id || "")
  .gte("progress", 0.01)
  .lte("progress", 0.99)
  .order("watched_at", { ascending: false })
  .limit(1);
const { data } = await supabase
  .from("video_progress")
  .select("video_id, watched_at")
  .eq("auth_uid", user.data.user?.id || "")
  .gte("progress", 0.01)
  .lte("progress", 0.99)
  .order("watched_at", { ascending: false })
  .limit(1);

Feedback

Feedback is incredibly important for me. I wanted to make it very easy for users to provide it. There is one form at the bottom of each page and at the end of each video. react-player exposes an onEnded prop, which is used to toggle the visibility of the form.

Feedback form in animations on the web.

The form at the bottom of each lesson.

I also made a very simple Next.js app to render the feedback as the Supabase UI is quite cramped, especially on mobile.

import { supabase } from "./utils/supabase";
 
export const dynamic = "force-dynamic";
 
export default async function Page() {
  const { data } = await supabase
    .from("feedback")
    .select()
    .limit(10)
    .order("created_at", { ascending: false });
 
  return (
    <div className="my-24 space-y-8">
      {data.map((feedback) => (
        <div className="rounded-xl p-4 text-sm shadow-sm" key={feedback.email}>
          <div className="mb-4 flex items-center justify-between">
            <span className="text-sm font-medium">{feedback.email}</span>
            <span className="font-mono text-[11px]">{feedback.page}</span>
          </div>
          <p className="opacity-75">{feedback.content}</p>
        </div>
      ))}
    </div>
  );
}
import { supabase } from "./utils/supabase";
 
export const dynamic = "force-dynamic";
 
export default async function Page() {
  const { data } = await supabase
    .from("feedback")
    .select()
    .limit(10)
    .order("created_at", { ascending: false });
 
  return (
    <div className="my-24 space-y-8">
      {data.map((feedback) => (
        <div className="rounded-xl p-4 text-sm shadow-sm" key={feedback.email}>
          <div className="mb-4 flex items-center justify-between">
            <span className="text-sm font-medium">{feedback.email}</span>
            <span className="font-mono text-[11px]">{feedback.page}</span>
          </div>
          <p className="opacity-75">{feedback.content}</p>
        </div>
      ))}
    </div>
  );
}

Want more?

By signing up for the newsletter below, you'll receive behind-the-scenes updates from the course every few weeks and you'll be notified when registration re-opens.

 
It's currently closed, as I want to ensure that existing users have the best experience they can get before I let more people in.