How This Site Was Built: Next.js 16, Tailwind v4, and a Dark Amber Design System
A technical walkthrough of building a consulting site with Next.js 16, React 19, Tailwind CSS v4, glass morphism, scroll-triggered animations, and an MDX blog — all deployed on Vercel.
I rebuilt my personal site from scratch as a consulting homepage. Not a template — a ground-up implementation with a custom design system, composable animation primitives, and an MDX blog. Here's how it all fits together.
The Stack
- Next.js 16 with App Router (SSR + static generation)
- React 19 with server components
- Tailwind CSS v4 — no config file, everything lives in CSS
@themeblocks - shadcn/ui for accessible base components (Radix UI primitives underneath)
- Framer Motion for scroll-triggered animations
- MDX via
next-mdx-remotefor blog content with syntax highlighting - Vercel for deployment with image optimization (AVIF/WebP)
The key shift from previous Next.js versions: Tailwind v4 eliminates tailwind.config.ts entirely. The design system is defined in pure CSS.
Design System: Dark Amber on Zinc
The entire color system lives in globals.css using Tailwind v4's @theme directive:
@theme {
--color-background: #09090b;
--color-foreground: #fafafa;
--color-primary: #f59e0b;
--color-primary-dark: #d97706;
--color-primary-light: #fbbf24;
--color-primary-glow: oklch(0.75 0.18 75 / 0.15);
}A few decisions that shaped the visual identity:
Six-level surface hierarchy. Instead of just "background" and "card," there are six surface levels from surface-lowest (#000) to surface-highest (#27272a). This lets glass cards, modals, and overlays stack without ad-hoc color choices.
OKLCH for glows and transparency. The amber glow effects use OKLCH color space for perceptually uniform opacity:
--shadow-glow: 0 0 30px oklch(0.75 0.18 75 / 0.15),
0 0 60px oklch(0.75 0.18 75 / 0.08);Always dark. There's no light mode toggle. The html element gets className="dark" and that's it. Dark themes are easier to make look sophisticated, and for a consulting site, a consistent brand impression matters more than theme flexibility.
Component Architecture
The site is built from three layers of components:
1. Section Components
The homepage is composed from independent section components — Hero, Services, Process, About, BlogPreview, Contact, CTA. Each is self-contained with its own data and layout. The page file is just composition:
<Hero />
<Services />
<Process />
<About />
<BlogPreview />
<Contact />
<CTA />2. Animation Primitives
Rather than sprinkling Framer Motion throughout, there are two reusable animation wrappers.
FadeIn handles scroll-triggered reveals with configurable direction:
export function FadeIn({
children,
delay = 0,
duration = 0.6,
direction = "up",
once = true,
amount = 0.3,
}: FadeInProps) {
const ref = useRef<HTMLDivElement>(null)
const isInView = useInView(ref, { once, amount })
const variants: Variants = {
hidden: { opacity: 0, ...directionOffset[direction] },
visible: {
opacity: 1, y: 0, x: 0,
transition: { duration, delay, ease: [0.22, 1, 0.36, 1] },
},
}
return (
<motion.div ref={ref} initial="hidden"
animate={isInView ? "visible" : "hidden"}
variants={variants}>
{children}
</motion.div>
)
}The easing curve [0.22, 1, 0.36, 1] (quint ease-out) gives a fast start with a long, smooth deceleration. Every section on the page wraps its content in <FadeIn> with staggered delays.
TextRevealByWord does kinetic typography — each word fades in with a blur effect as it enters the viewport. Used sparingly in the hero for the main heading.
3. Glass Morphism System
The GlassCard component provides three intensity levels:
const intensityClasses = {
subtle: "bg-surface-high/40 backdrop-blur-sm border-border/20",
normal: "bg-surface-high/60 backdrop-blur-md border-border/30",
strong: "bg-surface-high/80 backdrop-blur-lg border-border/50",
}On hover, cards get a primary-colored border glow and a subtle lift (translate-y-[-2px]). The effect is restrained — glass morphism works best when you resist the urge to use it everywhere.
The Blog System
Blog posts are MDX files in content/blog/. The pipeline:
- gray-matter parses YAML frontmatter (title, date, tags, description)
- next-mdx-remote/rsc renders MDX server-side — no client JS for blog content
- rehype-pretty-code with Shiki handles syntax highlighting (github-dark theme)
- remark-gfm adds GitHub-flavored markdown (tables, strikethrough, task lists)
- generateStaticParams pre-builds all post pages at build time
Custom MDX components override every HTML element with styled versions — headings get tracking adjustments, code blocks get the right font, links get hover transitions. Everything stays consistent with the design system.
Typography
The site uses Geist Sans and Geist Mono from Next.js's font system. Geist is a good match for technical content — clean, slightly geometric, excellent at small sizes. The mono variant handles inline code and the blog's code blocks.
Font smoothing is enabled globally (-webkit-font-smoothing: antialiased) which matters on dark backgrounds where subpixel rendering can make light text look too heavy.
Animation Timing
Two custom easing curves are defined as CSS custom properties:
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);CSS keyframe animations use these for page-load effects (fade-in, slide-in), while Framer Motion handles scroll-triggered reveals. The gradient background uses a 20-second blob-float animation for subtle ambient motion.
What I'd Do Differently
Make the blog preview dynamic. Right now the homepage blog section uses hardcoded post data rather than reading from MDX at build time. A server component pulling from getAllPosts() would keep things in sync automatically.
Add RSS. A route.ts handler generating an RSS feed from the MDX posts would take 20 minutes and make the blog actually subscribable.
Explore view transitions. Next.js is getting closer to native view transition support. Page-to-page animations between the blog index and individual posts would add polish without much complexity.
The full source is on GitHub.