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 only glows — it never floods the UI. And it's theme-aware: #F43F5E on light, lightened to #FB7185 on dark, because the base rose sits below 4.5:1 contrast on near-black surfaces.
// src/tokens.ts
export const colors = {
accent: "#F43F5E",
accentLight: "#FB7185",
accentDark: "#BE123C",
accentMuted: "rgba(244,63,94,0.1)",
accentBorder: "rgba(244,63,94,0.25)",
accentGlow: "rgba(244,63,94,0.18)",
};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: #fdfbf6;
--color-secondary: #111111;
--color-card: #f6f2ea;
--color-surface: #f6f2ea;
--color-surface2: #eee7da;
--color-border: #e5ddd0;
--color-accent: #f43f5e;
}
.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: Nunito, with a System Fallback
Typography grounds the tone. Body copy and UI text run on Nunito, loaded via a single Google Fonts @import — it's warm and rounded without being decorative, which keeps the "quiet, data-led" feel of the rest of the system. The system stack (-apple-system, SF Pro Display, etc.) sits behind it purely as a fallback for the moment before the webfont paints, not as the primary face.
@import url("https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
--font-sans:
"Nunito", -apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui,
sans-serif;The scale is radical: exactly two font sizes, both fluid.
:root {
--fs-display: clamp(1.625rem, 1.25rem + 1.9vw, 2.25rem); /* h1/h2 only */
--fs-base: clamp(0.9375rem, 0.9rem + 0.25vw, 1rem); /* everything else */
}Page titles and the hero name get --fs-display. Everything else — body, section headers, captions, tags, code — is --fs-base. Hierarchy inside each size comes from weight, color, and typeface, never from a third size. Section headers (.text-h3) are base-size display-font labels, not competing headings — when only one thing on the page is big, that thing owns the page.
(Heading tracking is looser than a typical sans scale — monospace glyphs already run wide, so negative tracking beyond -0.01em makes characters collide.)
The ::selection state uses accent on white — consistent in both themes.
A Second Typeface for Headers Only
One typeface for everything reads as "I didn't choose, I accepted the default." So headers, the wordmark, and primary nav now run on JetBrains Mono — a technical, developer-tool face that says something specific about an infrastructure/backend identity, the same way a hand-drawn avatar says something about a design-focused one.
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;700&display=swap");
--font-display: "JetBrains Mono", var(--font-mono);.text-h1, .text-h2, and .text-h3 reference --font-display instead of --font-sans. That's it — no component-level overrides, because everything that needs the display treatment already routes through those three classes (page titles, section headers, the mobile nav drawer).
The rule: display font on headers and nav, never on body copy or .prose. Blog post headings (.prose h1/h2/h3) are a deliberately separate rule that still points at --font-sans — a reader's paragraph-to-paragraph flow shouldn't be broken by a heavier, wider face mid-article. Two typefaces is a system only if each one has exactly one job.
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 },
},
},
fadeIn: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { ease: [0.215, 0.61, 0.355, 1], duration: 0.4 },
},
},
scaleIn: {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { ease: [0.215, 0.61, 0.355, 1], duration: 0.4 },
},
},
staggerContainer: {
hidden: {},
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(244,63,94,0.3),
// 0 4px 20px rgba(244,63,94,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(244,63,94,0.3), 0 4px 20px rgba(244,63,94,0.15)";/* src/styles.css */
--quartz-glow:
0 0 0 1px rgba(244, 63, 94, 0.3), 0 4px 20px rgba(244, 63, 94, 0.15);(The token is still named quartzGlow — that's the pattern's name now, not a literal color description, the same way "Tailwind" doesn't describe fabric softener.)
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.
Rest state gets its own, much quieter token — --shadow-card — so a card reads as "raised, clickable" even in a static screenshot, not only on hover:
/* Light: a real shadow works against a white surface */
--shadow-card:
0 1px 2px rgba(17, 17, 17, 0.04), 0 1px 1px rgba(17, 17, 17, 0.03);
/* Dark: a black shadow is invisible on a near-black surface —
lift with a faint inset top highlight instead */
--shadow-card: inset 0 1px 0 rgba(255, 255, 255, 0.04);Two elevation tiers, two jobs: --shadow-card says "this is a surface"; quartzGlow says "this is being interacted with."
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.
The Connector Motif
The <Logo> mark in the hero is nodes connected by lines — a graph. That vocabulary shouldn't be a one-off icon. It recurs as <Divider> between page sections, and — more literally — as <PipelineRail> running down the homepage's Featured Work list: one node per project, on a shared line.
That's not decoration. The subject of the page is distributed systems and pipelines. A connector linking discrete stages is a picture of the actual work, not just a shape that happened to look nice.
The first version had a bug: the line simply stopped after the last card, mid-height, with no visual resolution. Fixed by letting every row's line reach its own card's bottom edge — including the last one — then capping it with a small dot right at that edge. The thread now ends on purpose instead of trailing off.
// src/components/ui/PipelineRail.tsx
{
isLast && (
<div className="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
<div className="h-1.5 w-1.5 rounded-full bg-border" />
</div>
);
}The second bug was more interesting: live and wip projects were the same-size circle, differing only by fill opacity. That's a single signal, and a weak one — indistinguishable at a glance for colorblind viewers, or anyone glancing quickly. The fix pairs two independent signals instead of one: fill (solid vs. hollow — a luminance difference that survives grayscale) and shape (a Check vs. a Minus, not the same silhouette at different opacities). Color is still there, but it's reinforcement, not the whole signal.
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.
Cards follow the same contract at lower intensity — whileHover={{ scale: 1.01, boxShadow: quartzGlow }}, whileTap={{ scale: 0.99 }} — enough to feel alive without competing with buttons.
Tags have exactly two variants: default (neutral surface) and accent (accent border/text on accent-muted). On project cards only the first tag gets accent — color contrast on one key word, not a rainbow.
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.