How I Created Hritul.com's Design System
A design system isn't a set of components. It's a worldview. Here's the full story behind hritul.com's dual-theme tokens, motion philosophy, and component language.
When I started rebuilding hritul.com, I didn't set out to make another portfolio. I wanted a living design system — a single source of truth that's equal parts aesthetic language, interaction philosophy, and technical contract.
The website wasn't the goal. The system was.
Why Build a System First
A portfolio for a design engineer isn't a landing page — it's a proof of execution. Every padding value, easing curve, and hover state is a signal of how you design, code, and think.
I've always struggled with "art-directed" sites that look beautiful in a screenshot but fall apart in implementation. This time, I wanted the reverse: a system that makes good design inevitable.
So I started where most designers don't — with a spec, not a mockup.
The Color System
Color was the first principle I solved. The rules were simple:
- Never hardcode hex. Everything is a semantic token.
primary= background.secondary= text — inverted by design.- Accent exists, but it never paints more than a badge or pill's worth of surface.
Both themes were designed simultaneously. Identical contrast ratios, identical visual rhythm. The accent (#6C63FF) only glows — it never floods the UI.
// src/tokens.ts
export const colors = {
accent: '#6C63FF',
accentLight: '#8E87FF',
accentDark: '#4A3FCC',
accentMuted: 'rgba(108,99,255,0.1)',
}All semantic tokens live as CSS custom properties in src/styles.css and get mapped into Tailwind via @theme:
/* src/styles.css */
:root {
--color-primary: #FFFFFF;
--color-secondary: #111111;
--color-card: #F5F5F5;
--color-surface: #F5F5F5;
--color-surface2: #EBEBEB;
--color-border: #E0E0E0;
--color-accent: #6C63FF;
}
.dark {
--color-primary: #0F0F0F;
--color-secondary: #F0F0F0;
--color-card: #1A1A1A;
--color-surface: #1A1A1A;
--color-surface2: #252525;
--color-border: #2E2E2E;
}The result: a quiet, data-led palette that's accessible in both themes without a single hardcoded hex in any component.
Typography: SF Pro, No Alternatives
Typography grounds the tone. I chose SF Pro because it ships with every Apple device — no HTTP request, no FOUT, no layout shift. It resolves natively from the OS stack.
--font-sans: -apple-system, BlinkMacSystemFont,
'SF Pro Display', 'SF Pro Text',
system-ui, sans-serif;All type is defined in a CSS scale, not in JS:
.text-h1 { font-size: clamp(2.5rem, 5vw, 4.5rem); font-weight: 700; letter-spacing: -0.04em; }
.text-h2 { font-size: clamp(2rem, 4vw, 3.5rem); font-weight: 700; letter-spacing: -0.03em; }
.text-h3 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; }
.text-body { font-size: 1rem; font-weight: 400; line-height: 1.6; }
.text-small { font-size: 0.875rem; font-weight: 400; letter-spacing: -0.005em; }
.text-caption { font-size: 0.75rem; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; }The ::selection state uses accent on white — consistent in both themes.
Building Motion Like a Product
Most design systems stop at color and type. I treated motion as a first-class citizen.
Instead of arbitrary keyframes, motion tokens are defined like constants and imported directly:
// src/tokens.ts
export const easing = {
entrance: [0.215, 0.61, 0.355, 1], // ease-out-cubic
exit: [0.55, 0.055, 0.675, 0.19],
move: [0.645, 0.045, 0.355, 1], // ease-in-out-cubic
hover: [0.25, 0.46, 0.45, 0.94],
}
export const duration = {
micro: 0.1,
fast: 0.15,
base: 0.2,
slow: 0.3,
reveal: 0.6,
}
export const spring = {
nav: { type: 'spring', duration: 0.4, bounce: 0.1 },
snap: { type: 'spring', duration: 0.3, bounce: 0.1 },
}Every animation obeys three rules:
- Only
transformandopacity. Never height, padding, width, or any layout property. - Every instance respects
useReducedMotion(). Pass{}(empty object) — don't skip the prop. - Duration matches real-world physics. A button press is
0.15s. A sidebar spanning 100vh is0.55–0.7s.
Shared variants live in tokens.ts so they're never duplicated per-component:
export const variants = {
fadeUp: {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0,
transition: { ease: [0.215, 0.61, 0.355, 1], duration: 0.5 } },
},
staggerContainer: {
visible: { transition: { staggerChildren: 0.08 } },
},
}me
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.97 }}
transition={spring.snap}
// spring.snap = { type:'spring',
// duration:0.3, bounce:0.1 }whileHover={{
scale: 1.02,
boxShadow: quartzGlow,
}}
// 0 0 0 1px rgba(108,99,255,0.3),
// 0 4px 20px rgba(108,99,255,0.15)initial={{ opacity:0, scale:0.8, y:12 }}
animate={{ opacity:1, scale:1, y:0 }}
transition={{ type:'spring',
duration: 0.5, bounce: 0.2 }}// parent: variants.staggerContainer // staggerChildren: 0.08 // each child: variants.fadeUp // ease-out-cubic · duration 0.5
The Quartz Glow
I wanted hover and focus states to feel tangible without heavy shadows or gradients. The answer was a single reusable token:
// src/tokens.ts
export const quartzGlow =
'0 0 0 1px rgba(108,99,255,0.3), 0 4px 20px rgba(108,99,255,0.15)'/* src/styles.css */
--quartz-glow: 0 0 0 1px rgba(108,99,255,0.3),
0 4px 20px rgba(108,99,255,0.15);The rule: only on hover and focus. Never at rest. If everything glows, nothing does.
It applies on whileHover for interactive cards and buttons, and on :focus-visible for inputs. One token, used consistently, instead of five slightly different shadow values scattered across the codebase.
Rounded by Intention
Rounded corners here aren't decorative — they're structural. Every container has a minimum 8px radius. Nothing is sharp.
export const radius = {
xs: '4px', // inline badges, code blocks
sm: '8px', // icon containers, small chips
md: '12px', // inputs, small cards
lg: '16px', // primary cards, panels
xl: '24px', // modals, large panels
full: '9999px', // pills, circular buttons
}The ThemeToggle expands across the viewport using the View Transitions API — circular reveal from the click point. The geometry stays consistent because the shape language is consistent.
Component Language
Three principles drive every component:
- Minimum viable variants. Buttons have three:
primary,ghost,text. Nothing else exists until it appears in two places and proves its worth. - Filled, not bordered. Cards use
bg-card(filled background) with no border — contrast comes from the surface, not from a stroke. - Animated only on interaction. Hover scale, glow, and tap feedback are universal. Nothing animates at rest.
Each button variant has a specific hover contract:
// primary — inverted fill, scale + glow
whileHover={{ scale: 1.02, boxShadow: quartzGlow }}
// ghost — outline, scale only
whileHover={{ scale: 1.02 }}
// text — no motion hover, CSS opacity only
// hover:opacity-75All share whileTap={{ scale: 0.97 }} and transition={spring.snap}. One tap feedback. No exceptions.
There's a practical note about class ordering. tailwind-merge treats all text-{value} classes as the same group. If you write text-primary text-small, twMerge drops text-primary (last wins). The correct order: text-small font-medium text-primary — typography class first, color last.
Icons follow the same interaction language. Every clickable Lucide icon and the <Logo> SVG uses hover:text-accent transition-colors. One hover color, applied consistently, instead of scattered opacity or muted-grey hovers. The ThemeToggle is the only exception — its Sun/Moon swap already has a spring animation.
The Single Source of Truth
DESIGN_SYSTEM.md is the contract. src/tokens.ts is its runtime expression. src/styles.css is its CSS expression.
Before writing any UI code, I read the relevant section. Before changing any token, I update the file first. This applies to me, to collaborators, and to AI agents working in the codebase.
That's how consistency scales — not through enforcement, but through encoding judgment.
Closing Thought
A design system isn't a set of components. It's a worldview.
Hritul.com's design system is mine — minimal, structured, and alive. Less about what I built, more about how I think.
If the interface feels calm, predictable, and quietly precise: the system worked.