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.

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 itself is theme-dependent, mirroring styles.css
  accent: { light: "#F43F5E", dark: "#FB7185" },
  accentMuted: "rgba(244,63,94,0.1)",
  accentBorder: "rgba(244,63,94,0.25)",
  accentGlow: "rgba(244,63,94,0.18)",

  // Light neutrals — warm white
  white: "#FDFBF6", surface: "#F6F2EA", surface2: "#EEE7DA", border: "#E5DDD0",

  // Dark neutrals
  obsidian: "#0F0F0F", ash: "#1A1A1A", smoke: "#2E2E2E", snow: "#F0F0F0",
};

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-accent: #fb7185; /* the theme-aware line the paragraph above is about */
  --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 started 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 */
}

It's three now, and the third one was earned rather than chosen — that story is the next section.

Page titles and the hero name get --fs-display. Body, section headers, and code are --fs-base; only metadata — labels, tags, timestamps — drops to --fs-caption. Hierarchy inside each size still comes from weight, color, and typeface. 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.

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
Three sizes: --fs-display (h1/h2) · --fs-base (body) · --fs-caption (metadata) · Nunito body · SF Pro Display headings

Hierarchy When You've Given Up a Size

Two font sizes is a nice constraint to write about and a genuinely hard one to live with, because it removes a lever most hierarchy advice assumes you have.

The usual formula is size, weight, and colour. Below --fs-display there was no size lever at all for a long time — .text-h3, .text-body, .text-small, and .text-caption all resolved to the same --fs-base, differing only by weight, tracking, and case. That leaves weight, colour, typeface, and — the one everyone forgets — spacing.

For a while the homepage cards proved the point by failing. Card title, description, outcome, and tags all rendered at the same size with the same gap-3 between them, so nothing grouped, and the only thing that stood out was a line of accent-red text — which meant the supporting line was louder than the project name. Hierarchy had inverted, and colour was propping up the whole thing.

The fix used every remaining lever at once. Project cards now run a four-step ladder:

TierTreatment
Titledisplay face, 600, full contrast
Outcomesans, 500, full contrast, one small accent marker
Descriptionsans, 400, text-secondary/65
Tags & status--fs-caption, text-secondary/60, uppercase

Four distinguishable levels, and only the last one uses a size change. The display face alone separates tier 1 from tier 2 without touching weight or colour.

Spacing does the grouping, and the rule is only this: the gap between groups must beat the gap inside them. In the service lists that's 4px between a capability and its outcome, 16px between one capability and the next, 24px of card padding. A 4× ratio is unmissable; the old uniform 12px was invisible.

Where two sizes finally broke

The constraint held everywhere except one place, and it took a while to see it: uppercase caption text at --fs-base outweighs the sentence-case title it labels.

A blog card read CODING · MARCH 12, 2026 above OpenTelemetry in Rust: From Zero to Production Observability. Same font size, but the metadata was uppercase and letter-spaced, so it occupied more visual space and read louder — while carrying strictly less information. Every card on the site had its hierarchy inverted at the top.

Weight couldn't fix it: caption is already 500 against the title's 600. Colour couldn't either — the label was at /60 and pushing it lighter would have failed contrast. Uppercase is doing the work of a smaller size, so the honest fix was a smaller size:

--fs-caption: clamp(0.75rem, 0.72rem + 0.15vw, 0.8125rem); /* metadata only */

It's deliberately narrow in scope: labels, tags, timestamps. Not a general "small text" escape hatch. The rule became never a fourth size, and --fs-caption only ever goes down from base.

The lesson isn't that the two-size constraint was wrong — it was right for a long time and forced better decisions about weight and spacing than a big scale ever would have. It's that a constraint is worth keeping until it makes you ship something worse, and "the date is louder than the headline" is worse.

Two measurements worth keeping, because both contradicted what the code looked like:

  • max-w-prose is 65 ch, and ch is the width of a zero. In Nunito that came out to 79 characters per line — well past the 45–75 range where lines stay easy to track. max-w-[56ch] lands at 68.
  • Opacity is not a contrast measurement. text-secondary/40 and /55 looked fine and measured 2.81:1 and 4.06:1 against bg-card — both failures. They're /60 and /65 now, at 4.76:1 and 5.66:1.

Measuring that second one has a trap: Tailwind v4 emits oklch() and color-mix(), so a naive regex over getComputedStyle().color returns nonsense. Painting the colour onto a 1×1 canvas and reading the pixel back normalises any syntax, and is the only reason the failures surfaced at all.

The tests that catch this are cheap and neither involves opinion — screenshot the section with filter: grayscale(1) and again with filter: blur(2.5px). If the hierarchy survives both, it was never leaning on colour.

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 run on a second face, routed through one token.

The first attempt was JetBrains Mono — a technical, developer-tool face, chosen because it says something specific about an infrastructure identity the way a hand-drawn avatar says something about a design-focused one. It didn't survive contact with the hero.

Monospace has a fixed advance width, which is exactly what you want in a terminal and exactly what you don't want in a headline. At display size, "Distributed Systems & GenAI Engineer for SaaS Startups" ran so wide it forced a smaller type size to fit — the headline got quieter the more important it became. Tracking couldn't rescue it either: monospace glyphs already run wide, so pulling past -0.01em made characters collide before the line got meaningfully shorter.

So --font-display now points at the system stack:

--font-display:
  -apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif;

SF Pro Display is drawn for exactly this job — tight, optical-sized, and it tightens further under negative tracking instead of falling apart. It also costs zero bytes and zero render-blocking requests, which the webfont did not.

The lesson worth keeping: a typeface choice that only works in a screenshot isn't a system choice. The mono face read beautifully on a short wordmark and broke on the one line the page exists to deliver.

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

const _entrance = [0.215, 0.61, 0.355, 1] as const;

export const variants = {
  fadeUp: {
    hidden: { opacity: 0, y: 16 },
    visible: {
      opacity: 1,
      y: 0,
      transition: { ease: _entrance, duration: 0.5 },
    },
  },
  fadeIn: {
    hidden: { opacity: 0 },
    visible: { opacity: 1, transition: { ease: _entrance, duration: 0.4 } },
  },
  scaleIn: {
    hidden: { opacity: 0, scale: 0.95 },
    visible: {
      opacity: 1,
      scale: 1,
      transition: { ease: _entrance, 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. Containers start at 8px; only inline badges and code chips go down to 4px. 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>, 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.

<Divider> learned the harder half of that lesson. It was originally dropped between every page section, which turned a motif into wallpaper: eight on a page, none meaning anything. The layout already puts gap-18 between sections, so the rule was doing no separating work — it was padding wearing a costume. It's down to three deliberate uses, and the only interesting one is the pair that hugs the disruptor statement on the homepage. Two rules close together read as a band and give the sentence between them weight. The same component, used four times fewer, does more.

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 (bg-secondary), scale + glow
whileHover={{ scale: 1.02, boxShadow: quartzGlow }}

// ghost — filled card surface + hairline border, scale + glow
whileHover={{ scale: 1.02, boxShadow: quartzGlow }}

// text — no motion hover, CSS opacity only
// hover:opacity-75

The nav CTA is worth a paragraph, because it took four attempts to get right.

Nav links are deliberately uniform, so "Contact Me" had no way to read as the destination that matters. The first fix gave it a ghost pill; the second promoted that to a fourth Button variant with an accent border. Both failed the same way: a pill in a row of plain text is a different kind of object, so it didn't read as emphasis, it read as a button that had wandered into a sentence. It was also the loudest thing in the viewport, competing with the headline it sits above. And it cost the anchor — Button renders a <button>, so no href, not middle-clickable, invisible to a crawler. Bad trade on a primary nav item.

The third attempt dropped the shape and used font-bold text-accent. Better, but bolder-and-redder still just makes one word shout.

What worked was changing what's being communicated. The label now sits at exactly the same size, weight, and colour as every other link, and takes a small pulsing accent dot in front of it:

.pulse-dot::after {
  animation: pulse-ring 2.4s var(--ease-out) infinite; /* scale + opacity only */
}

The dot isn't decoration standing in for emphasis — it means something specific. A slow pulse on a status marker reads as live, the same vocabulary as the live indicator on project cards. It says "available for work" rather than "this text is important", and motion draws the eye at a size that occupies almost no visual weight. Everything else in the row stays untouched.

It also freed the active state to keep plain accent text, since the CTA no longer competes on colour — the dot distinguishes it.

Under prefers-reduced-motion the ring is removed and the solid dot remains, so the marker survives without anything moving.

The general lesson: reach for a new component last. Three attempts added structure to solve what a 6px dot solved by saying something truer.

ghost used to be a transparent outline that only scaled, and it drifted. The theme toggle had quietly grown its own near-identical "circle with a faint border on a card background" styling, which meant two components were maintaining the same idea separately. Collapsing them meant admitting ghost was already a filled variant in practice — bg-card border border-secondary/15 — and that the difference from primary is surface weight, not fill vs. no-fill.

That unlocked a second axis instead of a fourth variant: size: "icon" (h-10 w-10, p-0, rounded-full) makes the toggle just <Button variant="ghost" size="icon">. Rest styling belongs to the variant, hover only ever adds the glow, and sizeScale tunes travel per size — icon buttons are small enough that pill-sized travel reads as no travel at all, so they get 1.05 instead of 1.02.

There's a third prop worth naming: property="link" wraps the label in the .link underline-grow span, for buttons that read as navigation rather than action.

All variants share whileTap 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).

Project cards used to give the first tag accent — colour contrast on one key word, not a rainbow. That rule got retired once the cards grew an outcome line with its own accent marker. Two accents on one card is two focal points, which is none: the eye has to choose, and the tag was winning against the sentence that actually sells the project. One accent per card, on the thing you want read. The tags are uniform now.

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

What an Audit Turns Up

Every rule above was written before the code it governs. Reading the whole codebase back against them, a year of small decisions had quietly drifted:

  • 36 of 88 text-opacity values were below WCAG AA. Not one looked wrong.
  • 13 distinct card treatments — five radii, seven paddings, three shadow states. "Card" had stopped meaning anything.
  • Four page-title conventions, with h1 rendering at three different sizes.
  • Nine hover contracts across 26 hand-rolled buttons, most of them re-typing what Button already exported.

The most useful finding wasn't a value, it was a category error. Project cards on /lab rendered cursor-pointer, a hover lift, a glow and a reveal arrow — while having nothing to navigate to, because no project had an href. Every signal the design system has for "this is interactive" was firing on something inert. The card also carried an onKeyDown handler on a bare div with no tabIndex, so it could never receive focus: the keyboard path had been written and was unreachable.

Interactive is a state, not a default. It has to be a flag the component branches on, and everything — pointer, motion, affordance, role, tabIndex — hangs off it.

The other lesson is about measurement. Opacity is not contrast, and neither is confidence. text-secondary/40 and /55 looked completely fine and measured 2.81:1 and 4.06:1. The only way to find that is to compute it — and in Tailwind v4 that means painting each colour onto a 1×1 canvas and reading the pixel back, because a regex over getComputedStyle().color meets oklch() and returns nonsense.

One failure is still there on purpose. The brand rose is 3.29:1 on bg-card in light — accent text fails AA for body copy. Darkening it fixes the number and changes the brand. That's a product decision, not a lint rule, so it's documented as a known exception rather than quietly "fixed."

A system is only a contract if you re-read it against the code. Otherwise it's a document about a website that used to exist.

The Single Source of Truth

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