Challenge 10 — Landing Page + Design System
🇬🇧 Landing Page with Design System
Building a landing page is easy. Building one with a design system that doesn't break when someone changes the primary color — that's the hard part.
This challenge is about components that dress themselves, documented in Storybook, with real accessibility.
The Problem
A fintech landing page needs to look trustworthy and professional. But without a design system, every new page means reinventing buttons, cards, dialogs — and inconsistencies sneak in. Worse: when the brand color changes, you're hunting down hardcoded bg-violet-600 across a dozen files.
We needed components that are consistent, accessible, and document themselves so designers and backend devs can review without running the app.
Architecture
landing-page/
├── src/
│ ├── app/
│ │ ├── page.tsx # Home
│ │ └── layout.tsx # Global layout (Header + Footer)
│ ├── components/
│ │ ├── ui/ # Design system
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Dialog.tsx
│ │ │ └── Tooltip.tsx
│ │ └── sections/ # Page sections
│ │ ├── Hero.tsx
│ │ ├── Features.tsx
│ │ └── CTA.tsx
│ └── lib/
│ ├── tokens.ts # Colors, typography, spacing
│ └── cn.ts # classname merge
├── .storybook/
└── tailwind.config.tsThe stack: Next.js 14 for SSR, Radix UI for accessible primitives, Tailwind CSS for utility-first styling, Storybook for component documentation, and CVA for variant management.
TypeScript Implementation
Design tokens that work
// lib/tokens.ts
export const tokens = {
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
800: '#262626',
900: '#171717',
},
},
spacing: {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
8: '32px',
10: '40px',
12: '48px',
16: '64px',
},
} as const;Button with CVA (Class Variance Authority)
// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-2 focus-visible:outline-blue-600 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-violet-600 text-white hover:bg-violet-700',
secondary: 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200',
outline: 'border border-zinc-300 hover:bg-zinc-50',
ghost: 'text-zinc-600 hover:bg-zinc-100',
},
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}Dialog with Radix (actual accessibility)
// components/ui/Dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
export function Dialog({ children, ...props }: DialogPrimitive.DialogProps) {
return <DialogPrimitive.Root {...props}>{children}</DialogPrimitive.Root>;
}
export function DialogContent({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in" />
<DialogPrimitive.Content className={cn(
'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl',
'w-full max-w-md',
className
)}>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}cn() helper
// lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Testing (Storybook)
Every component gets its own story:
// Button.stories.tsx
import { Button } from './Button';
export default {
title: 'UI/Button',
component: Button,
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'outline', 'ghost'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
},
};
export const Primary = { args: { children: 'Click me', variant: 'primary' } };
export const Secondary = { args: { children: 'Cancel', variant: 'secondary' } };
export const Disabled = { args: { children: 'Disabled', disabled: true } };Run with pnpm storybook at localhost:6006.
Running
# Landing page
pnpm --filter @banking/landing-page dev
# http://localhost:3000
# Storybook
pnpm --filter @banking/landing-page storybook
# http://localhost:6006Lessons Learned
- Tailwind + CVA — You never write new CSS for each button. Variants are typed.
- Radix UI — Accessibility without thinking. Focus, keyboard, ARIA attributes. All ready.
- Storybook — Backend devs can see components without running the app. Designers can review without knowing React.
- cn() helper — Avoids Tailwind class conflicts when merging prop classes with fixed classes.