Design·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 (#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.

Light
#FFFFFF
Background
--color-primary
#F5F5F5
Surface
--color-surface
#EBEBEB
Surface 2
--color-surface2
#E0E0E0
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
#6C63FF — Quartz accent
Theme-invariant. Interactive states, focus rings, emphasis only.
var(--color-accent)

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.

ClassSample
.text-h1Design meets AI.
.text-h2Design meets AI.
.text-h3Design meets AI.
.text-bodyCrafting interfaces that feel inevitable.
.text-smallSupporting text and metadata.
.text-captionLABELS AND TIMESTAMPS
SF Pro system stack · No external font loading · 400 / 500 / 600 / 700 weights only

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 } },
  },
  staggerContainer: {
    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(108,99,255,0.3),
// 0 4px 20px rgba(108,99,255,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(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:

  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.

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
Badges — accent · neutral · muted
Design EngineerGenAIOpen to workRemoteMarch 2026Case study
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.