Design·March 2, 2026

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.

This post has been archived and may be outdated.

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:

  1. Never hardcode hex. Everything is a semantic token.
  2. primary = background. secondary = text — inverted by design.
  3. 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.

Light
#FDFBF6
Background
--color-primary
#F6F2EA
Surface
--color-surface
#EEE7DA
Surface 2
--color-surface2
#E5DDD0
Border
--color-border
#111111
Text
--color-secondary
#999999
Muted
--color-text-muted
Dark
#0F0F0F
Background
--color-primary
#1A1A1A
Surface
--color-surface
#252525
Surface 2
--color-surface2
#2E2E2E
Border
--color-border
#F0F0F0
Text
--color-secondary
#555555
Muted
--color-text-muted
#F43F5E — Quartz accent
Lightens to #FB7185 in dark theme for contrast. Interactive states, focus rings, emphasis only.
var(--color-accent)

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.

ClassSample
.text-h1 / .text-h2Design meets AI.
.text-h3Design meets AI.
.text-bodyCrafting interfaces that feel inevitable.
.text-smallSupporting text and metadata.
.text-captionLABELS AND TIMESTAMPS
Two sizes only: --fs-display (h1/h2) + --fs-base (everything else) · Nunito body · JetBrains Mono display

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:

  1. Only transform and opacity. Never height, padding, width, or any layout property.
  2. Every instance respects useReducedMotion(). Pass {} (empty object) — don't skip the prop.
  3. Duration matches real-world physics. A button press is 0.15s. A sidebar spanning 100vh is 0.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 } },
  },
};
Hover
me
Scale hover — spring(0.3, bounce 0.1)
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.97 }}
transition={spring.snap}
// spring.snap = { type:'spring',
//   duration:0.3, bounce:0.1 }
Quartz Glow Card
Quartz glow — hover / focus only
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)
Spring entrance — bounce: 0.2
initial={{ opacity:0, scale:0.8, y:12 }}
animate={{ opacity:1, scale:1, y:0 }}
transition={{ type:'spring',
  duration: 0.5, bounce: 0.2 }}
Design
Engineer
GenAI
Stagger reveal — staggerChildren: 0.08
// parent: variants.staggerContainer
// staggerChildren: 0.08
// each child: variants.fadeUp
// ease-out-cubic · duration 0.5
Entrances
ease-out-cubic
[0.215, 0.61, 0.355, 1]
Movements
ease-in-out-cubic
[0.645, 0.045, 0.355, 1]
Hover / color
ease-hover
[0.25, 0.46, 0.45, 0.94]

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.

Pipeline connector — live · wip · terminal
Live project
Work in progress

Component Language

Three principles drive every component:

  1. Minimum viable variants. Buttons have three: primary, ghost, text. Nothing else exists until it appears in two places and proves its worth.
  2. Filled, not bordered. Cards use bg-card (filled background) with no border — contrast comes from the surface, not from a stroke.
  3. 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-75

All 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.

Buttons — 3 variants only
Tags — default · accent (only the first tag in a list gets accent)
RAGAgentsVector Search
Inputs — focus activates accent border + quartz glow

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.