Polishing the Experience: Animations, Loading States, and Accessibility
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button
Welcome back to Part 4 of the series. In Phase 1 we set up the project and built the layout shell. In Phase 2 we tackled content rendering and dynamic routing. Now it is time for Phase 3: Polish.
This is the phase where a functional site starts to feel like a finished site. We are talking animations, loading skeletons, hover micro-interactions, link previews, and a round of accessibility fixes. None of this changes what the site does — it changes how the site feels.
I broke Phase 3 into three tasks:
- Animations + Micro-interactions — scroll-based reveal with Framer Motion, hover states on nav and cards
- Loading States — shimmer skeletons for perceived performance
- QA + Accessibility + LinkPreview — ARIA labels, keyboard nav, color contrast, and a fancy hover card for external links
Let's get into it.
Animations + Micro-interactions
Why bother with animations?
Static content that just appears on the page works fine technically. But a little motion guides the eye, creates rhythm, and makes the site feel intentional rather than thrown together. The key is restraint — animations should support comprehension, not distract from it.
I chose Framer Motion because it integrates cleanly with React, handles layout animations, and has a dead-simple API for scroll-triggered reveals. Alternatives like react-intersection-observer with manual CSS classes work too, but Framer Motion bundles the observer logic, the animation, and the cleanup into one declarative component. Less code, fewer bugs.
Setting up a global MotionConfig
Before diving into individual animations, I wrapped the app in a MotionConfig provider to set shared defaults:
"use client"
import { MotionConfig } from "framer-motion"
export function MotionProvider({ children }: { children: React.ReactNode }) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>
}The reducedMotion="user" prop tells Framer Motion to check the user's OS-level preference and skip animations if they have reduced motion enabled. We will come back to this in the accessibility section, but the point is: set it once at the root and forget about it.
The scroll-based reveal pattern
The core idea: elements start invisible and slightly offset, then animate into place when they scroll into the viewport. Here is the pattern I used across the site:
import { motion } from "framer-motion"
;<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<h2>Section title</h2>
<p>Content that fades in from below.</p>
</motion.div>Let's break down the important props.
viewport.once: true — This tells Framer Motion to only trigger the animation once. Without it, every time you scroll an element out of view and back in, it re-animates. That gets distracting fast. Once the user has seen the content appear, it should stay visible. Setting once: true accomplishes exactly that.
viewport.margin: "-50px" — By default, an element animates when it enters the edges of the viewport. The -50px margin shrinks that trigger zone by 50 pixels on every side, meaning the element has to scroll 50px past the viewport edge before the animation fires. This prevents content from animating too early when it is barely peeking into view.
transition.duration: 0.4 — Four-tenths of a second. Fast enough to not feel sluggish, slow enough to register as intentional motion. I experimented with 0.3 (too snappy) and 0.6 (too floaty) before landing here.
initial / whileInView — The element starts at opacity: 0 (invisible) and y: 20 (20 pixels below its final position). When it enters the viewport, it transitions to fully opaque at its natural position. The vertical slide gives the animation a sense of direction — content rising into place rather than fading in flatly.
A reusable FadeIn wrapper
I found myself repeating the same motion.div props on every section. So I extracted a reusable wrapper:
import { motion, type HTMLMotionProps } from "framer-motion"
export function FadeIn({ children, className, ...props }: HTMLMotionProps<"div">) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={className}
{...props}
>
{children}
</motion.div>
)
}Now any section on the site just needs:
<FadeIn>
<section>...</section>
</FadeIn>One line instead of five props. The component accepts all standard motion div props, so you can override transition or viewport on a per-use basis when needed.
Staggered reveals
For sections with multiple items (like the project cards on the home page), I stagger the animations so items appear one after another:
const container = {
hidden: {},
show: {
transition: {
staggerChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
}
;<motion.ul variants={container} initial="hidden" whileInView="show">
{projects.map((project) => (
<motion.li key={project.slug} variants={item}>
<ProjectCard {...project} />
</motion.li>
))}
</motion.ul>The staggerChildren: 0.1 means each child starts its animation 100ms after the previous one. It creates a nice cascade effect without being slow. If you have more than six or seven items, consider bumping the delay down to 0.05s — otherwise the last items take too long to appear.
Hover micro-interactions
Animations are not just for scroll. Hover states give interactive elements a tactile feel.
Nav icons scale up slightly when you hover over their parent link:
<a href="/blog" className="group flex items-center gap-2">
<BookOpen className="transition-transform duration-200 group-hover:scale-[1.2]" />
<span>Blog</span>
</a>The group-hover:scale-[1.2] Tailwind class scales the icon to 120% when the parent <a> is hovered. The transition-transform ensures it is smooth rather than a jarring jump.
Cards gain a deeper shadow on hover, lifting them visually:
<div className="rounded-lg border bg-white p-6 shadow-sm transition-shadow duration-200 hover:shadow-md dark:bg-gray-900">
{/* card content */}
</div>Again, transition-shadow keeps the change smooth. The difference between shadow-sm and shadow-md is subtle but noticeable — it suggests the card is rising off the page.
I also added a subtle border color shift on card hover:
<div className="rounded-lg border border-gray-200 transition-colors duration-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600">This is barely perceptible on its own, but combined with the shadow change, it reinforces the hover feedback. Small details compound.
Respecting prefers-reduced-motion
Not everyone wants animations. Some users experience motion sickness or vestibular disorders. The CSS media query prefers-reduced-motion lets us detect this preference and disable animations accordingly.
Framer Motion respects this natively — if the user has reduced motion enabled at the OS level, Framer Motion skips all transitions and jumps directly to the final state. Combined with the MotionConfig provider we set up earlier, this covers all Framer Motion animations across the app.
But I also added a manual CSS guard for the shimmer animation (which we will see next), since that one is pure CSS and outside Framer Motion's control:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}This is a blunt instrument, but it works. Every animation and transition on the page is effectively disabled. It is the kind of accessibility safeguard that costs nothing to include and can make a real difference for someone.
Loading States
The SSG reality check
Here is the thing about loading skeletons on a statically generated site: most pages are served as pre-built HTML. There is no loading spinner while data fetches from an API — the content is just there. So why did I bother with loading states?
Two reasons:
- MDX interactive components — Some of my blog posts include interactive demos that load client-side JavaScript. Those have a real loading gap between the static HTML appearing and the interactive widget hydrating.
- Good habits — If I ever add dynamic data fetching (comments, analytics dashboards, etc.), the pattern is already in place.
The skeleton pattern is cheap to implement and makes the site feel more robust. Even if most visitors never see it, the ones who do will have a better experience than staring at a blank rectangle.
The shimmer skeleton
The shimmer effect is that familiar loading placeholder — a gray rectangle with a highlight that sweeps across it, suggesting that content is on its way. It is purely cosmetic, but it reads much better than a blank space or a spinning wheel.
Here is the keyframe that drives the effect:
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
shimmer: {
"100%": { transform: "translateX(100%)" },
},
},
animation: {
shimmer: "shimmer 1.5s infinite",
},
},
},
}The shimmer animation translates a pseudo-element from left to right. Combined with a gradient that goes from transparent to a semi-white back to transparent, it creates the illusion of a light source sweeping across the skeleton.
Why translateX(100%) and not 100vw? Because the shimmer element is a child of the skeleton container, not the viewport. 100% moves it exactly the width of its parent — which is the width of the skeleton. That means the sweep speed scales with the skeleton size. A narrow text placeholder shimmers faster than a wide image placeholder, and both look natural.
The LoadingSkeleton component
import { cn } from "@/lib/utils"
function LoadingSkeleton({ className }: { className?: string }) {
return (
<div
className={cn("relative overflow-hidden rounded-md bg-gray-200 dark:bg-gray-800", className)}
>
<div className="animate-shimmer absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
)
}A few things to note:
relative/absolutepositioning — The parent isrelativeso the shimmer pseudo-element (actually a childdiv) can be positioned over it withabsolute inset-0.-translate-x-full— The gradient starts off-screen to the left. Theanimate-shimmerkeyframe then translates it totranslateX(100%), moving it across and off the right side.bg-gradient-to-r from-transparent via-white/20 to-transparent— A horizontal gradient that is transparent on both ends with a faint white band in the middle. The20%opacity keeps it subtle — you want a hint of shine, not a blinding glare.overflow-hidden— Essential. Without it, the gradient would be visible outside the skeleton's rounded corners as it slides in and out.- Dark mode variant —
dark:bg-gray-800swaps the base color for dark mode. The shimmer gradient useswhite/20which works on both light and dark backgrounds.
Using the component:
<div className="space-y-3">
<LoadingSkeleton className="h-4 w-3/4" />
<LoadingSkeleton className="h-4 w-1/2" />
<LoadingSkeleton className="h-32 w-full" />
</div>This creates a rough approximation of a text block — two short lines and a larger content area. It gives the user's brain something to latch onto while real content loads.
Composing skeleton layouts
For more complex loading states, I composed skeletons into recognizable page shapes. For example, a blog post card skeleton:
function PostCardSkeleton() {
return (
<div className="space-y-3 rounded-lg border p-4">
<LoadingSkeleton className="h-48 w-full rounded-lg" />
<LoadingSkeleton className="h-5 w-2/3" />
<LoadingSkeleton className="h-4 w-full" />
<LoadingSkeleton className="h-4 w-4/5" />
<div className="flex gap-2 pt-2">
<LoadingSkeleton className="h-6 w-16 rounded-full" />
<LoadingSkeleton className="h-6 w-16 rounded-full" />
</div>
</div>
)
}The shape mirrors the real card: a thumbnail, a title, two lines of description, and a couple of tag pills. When the real content swaps in, the layout does not jump — the skeleton occupies the same space. That continuity is the whole point.
QA + Accessibility + LinkPreview
LinkPreview with Radix Hover Card
One of the nice touches I wanted was preview cards for external links — when you hover a link, a small card pops up showing a thumbnail and description of the linked page. It helps users decide whether to click without leaving the page.
I built this with two pieces:
- @radix-ui/react-hover-card — handles the accessible popover behavior (focus management, keyboard dismissal, positioning)
- Microlink.io API — fetches metadata (title, description, image) for any URL
Here is the component:
"use client"
import * as HoverCard from "@radix-ui/react-hover-card"
import { useState } from "react"
import Image from "next/image"
interface LinkPreviewData {
title: string
description: string
image: string
url: string
}
export function LinkPreview({ url, children }: { url: string; children: React.ReactNode }) {
const [data, setData] = useState<LinkPreviewData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const fetchPreview = async () => {
if (data || isLoading) return
setIsLoading(true)
try {
const res = await fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`)
const json = await res.json()
if (json.data) {
setData({
title: json.data.title,
description: json.data.description,
image: json.data.image?.url ?? "",
url: json.data.url,
})
}
} catch {
// Silently fail — the link still works as a normal anchor
} finally {
setIsLoading(false)
}
}
return (
<HoverCard.Root openDelay={300} closeDelay={100}>
<HoverCard.Trigger asChild>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={fetchPreview}
onFocus={fetchPreview}
>
{children}
</a>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
side="top"
sideOffset={8}
className="z-50 w-80 overflow-hidden rounded-lg border bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onPointerDownOutside={(e) => e.preventDefault()}
>
{isLoading ? (
<div className="p-4">
<LoadingSkeleton className="h-32 w-full" />
<LoadingSkeleton className="mt-2 h-4 w-3/4" />
</div>
) : data ? (
<>
{data.image && (
<div className="relative h-40 w-full">
<Image src={data.image} alt="" fill className="object-cover" unoptimized />
</div>
)}
<div className="p-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{data.title}</p>
<p className="line-clamp-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
{data.description}
</p>
</div>
</>
) : null}
<HoverCard.Arrow className="fill-white dark:fill-gray-900" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
)
}A few design decisions worth calling out:
openDelay: 300— The preview does not pop up instantly. A 300ms delay prevents accidental triggers when the user is just moving the mouse across the page.closeDelay: 100— A short close delay keeps the card visible when the mouse briefly leaves the trigger area. Without it, the card flickers annoyingly.- Lazy fetching — Data is only fetched on hover or focus. The
onMouseEnterandonFocushandlers both callfetchPreview, so keyboard users get the preview too. No unnecessary API calls for links the user never interacts with. - Graceful failure — If the Microlink API fails, the component silently renders nothing. The link still works as a normal
<a>tag. No error messages, no broken UI. unoptimizedon Image — Microlink images come from unpredictable domains. Rather than adding every possible CDN toremotePatterns, I usedunoptimizedto skip Next.js image optimization for these previews. The images are already compressed by Microlink.
Using LinkPreview in MDX content
In my MDX components map, I wired LinkPreview so that any external link in a blog post automatically gets a preview:
const mdxComponents = {
a: ({ href, children }: { href: string; children: React.ReactNode }) => {
const isExternal = href.startsWith("http")
if (isExternal) {
return <LinkPreview url={href}>{children}</LinkPreview>
}
return <a href={href}>{children}</a>
},
}Internal links render as plain anchors. External links get the hover card treatment. The author does not need to do anything special — it just works.
next/image remotePatterns
Since LinkPreview loads images from external domains, I needed to whitelist those domains in the Next.js config. next/image requires explicit remotePatterns for security — it prevents your site from being used as an open image proxy.
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "res.cloudinary.com",
pathname: "/delba/**",
},
{
hostname: "api.microlink.io",
},
{
hostname: "pbs.twimg.com",
},
],
},
}Three domains are allowed:
- **
res.cloudinary.com/delba/**** — Cloudinary-hosted images used in blog content. Scoped to a specific path prefix to avoid opening up the entire Cloudinary CDN. api.microlink.io— Where Microlink serves preview thumbnails from.pbs.twimg.com— Twitter/X profile images and media. I use these in some project showcases.
Each pattern is as specific as it needs to be. The Cloudinary one uses pathname to restrict to a sub-path. The others only specify hostname because the paths are unpredictable.
Accessibility audit
With the visual polish in place, I did a proper accessibility pass. This was not an afterthought — I had been keeping accessibility in mind throughout development, but Phase 3 was where I did a systematic sweep.
ARIA labels on icon-only buttons. Any button that uses an icon without visible text needs an aria-label:
<button aria-label="Toggle dark mode" onClick={toggleTheme}>
<Sun className="h-5 w-5 dark:hidden" />
<Moon className="hidden h-5 w-5 dark:block" />
</button>Without the aria-label, a screen reader would announce this as just "button" — completely useless. I went through every icon button in the codebase and added labels. There were more than I expected.
Keyboard navigation. All interactive elements must be reachable and operable with a keyboard. The Radix Hover Card already handles this for the link previews (it opens on focus, closes on Escape). I also verified that:
- Tab order follows a logical reading sequence
- Focus indicators are visible (not just the browser default, but styled outlines that match the design)
- Modals and popovers trap focus correctly
- Skip-to-content link is the first focusable element
I added a custom focus ring style that works in both light and dark mode:
:focus-visible {
outline: 2px solid rgb(59 130 246);
outline-offset: 2px;
border-radius: 2px;
}The blue ring (rgb(59 130 246)) is the same blue used for links, so it feels consistent with the design system. outline-offset: 2px keeps the ring from touching the element's border, which improves legibility.
Color contrast. I ran the site through axe DevTools and Lighthouse. The main issues were:
- Some gray text on gray backgrounds in the footer — bumped the text color from
gray-400togray-500in light mode - The category tags had low contrast with their background — switched from pastel backgrounds to slightly more saturated ones
- Dark mode link text was too close to the surrounding paragraph color — made links brighter and added an underline
Most of these were one-line fixes. The hard part was finding them, not fixing them. Automated tools catch the obvious ones, but I also did a manual pass by squinting at the site in both modes and checking anything that looked faint.
Semantic HTML. I double-checked that headings follow a proper hierarchy (h1 > h2 > h3, no skipping levels), that lists use <ul> / <ol> instead of styled <div> elements, and that the <main> landmark wraps the primary content. The blog post layout already used a <main> element, but I added <article> around individual posts and <nav> around the table of contents for better screen reader navigation.
Alt text on images. Every image has a descriptive alt attribute. Decorative images get alt="" so screen readers skip them entirely.
<Image
src={project.thumbnail}
alt={`Screenshot of ${project.title} project`}
width={600}
height={400}
/>For the blog post cover images, I used the post's title as the alt text. It is not perfect — ideally the alt text would describe the image itself — but it is better than leaving it blank or repeating "blog post image" on every post.
Wrapping up Phase 3
Phase 3 did not add any new pages or features. What it did was make every existing page feel better. Scroll animations give the site a sense of flow. Hover states make interactive elements feel responsive. Loading skeletons handle edge cases gracefully. And the accessibility pass ensures the site works for everyone, not just mouse-using sighted visitors.
The LinkPreview component turned out to be one of my favorite additions. It is a small thing — a hover card — but it makes blog posts with lots of external references much more navigable. Users can preview a link without opening a new tab, which reduces friction and keeps them in the reading flow.
If there is one lesson from this phase, it is that polish is not about adding more — it is about refining what is already there. Every change in Phase 3 was small on its own. Together, they transform the site from "functional" to "finished."
The site looks and feels great. Phase 4 adds video support — a whole new content type.
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button