sailop
blogscanpricing
← Back to blog
March 20, 202612 min read

Building a Design System Generator from Scratch

A technical tutorial on building a procedural design system generator using HSL color spaces, seeded PRNGs, WCAG contrast calculations, and font pairing algorithms. Full code examples from Sailop's source.

Template marketplaces sell you a fixed set of choices. AI agents give you the same choices for free. Neither approach produces a design system that is truly yours. The third option is procedural generation: using algorithms and a seed value to produce an infinite number of unique, coherent design systems on demand. This is how Sailop works under the hood, and this article walks you through building a similar system from scratch.

If you want the conceptual overview first, read procedural design systems and how Sailop generates infinite palettes. If you want to understand why this matters, our article on why every AI-generated website looks the same covers the problem space. This article is pure code.

Why HSL Is Better Than RGB for Generation

RGB is the color model most developers know, but it is terrible for procedural generation. In RGB, there is no intuitive relationship between #3B82F6 and "a medium-saturated blue." Shifting any one channel produces unpredictable visual results. You cannot walk through RGB space and get a coherent palette.

HSL (Hue, Saturation, Lightness) solves this. Each dimension maps to a perceptual quality:

  • Hue (0-360): The color itself. 0 is red, 120 is green, 240 is blue.
  • Saturation (0-100%): How vivid the color is. 0% is gray, 100% is pure color.
  • Lightness (0-100%): How bright the color is. 0% is black, 50% is the pure color, 100% is white.
HSL Color Wheel (simplified):

        0/360 = Red
           |
   330     |     30
    \      |      /
     \     |     /
300 ------ + ------ 60
  Magenta  |     Yellow
     /     |     \
    /      |      \
   270     |     90
   Blue    |    Green
      \    |    /
       \   |   /
   240 --- + --- 120
        210  150
         Cyan

With HSL, generating a palette is straightforward: pick a base hue, then derive complementary, analogous, and accent hues using angular offsets. Adjust saturation and lightness to create shade scales. Everything stays perceptually coherent because the math maps directly to human color perception.

The AI Band: Hue 200-290

Before we generate colors, we need to define what to avoid. We analyzed thousands of AI-generated websites and found that AI tools overwhelmingly favor hues in the 200-290 range. This is the AI band: the zone of blues, indigos, and purples that dominate AI-generated output.

Hue distribution in AI-generated sites:

  0   30   60   90  120  150  180  210  240  270  300  330  360
  |    |    |    |    |    |    |    |    |    |    |    |    |
  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████████████████░░░░░░░░░░
                                |   AI BAND (200-290)  |
                                |   72% of all AI      |
                                |   color choices      |

  Hues outside the AI band:
  Red (0-30)        3.2%
  Orange (30-60)    2.1%
  Yellow (60-90)    1.8%
  Green (90-150)    6.4%
  Teal (150-200)    4.3%
  Blue (200-240)    38.7%    <-- AI band
  Indigo (240-270)  21.2%    <-- AI band
  Purple (270-290)  12.4%    <-- AI band
  Magenta (290-330) 5.1%
  Rose (330-360)    4.8%

Sailop's generator avoids or de-prioritizes the AI band. This alone makes a dramatic difference in how "AI" a site looks. A warm amber palette (hue ~35) reads as intentional and branded. A cool blue-600 palette (hue ~220) reads as default.

Seeded PRNG: Reproducible Randomness

We need randomness for variety, but we also need reproducibility. If you generate a design system today, you need to get the same result tomorrow when you run the same command. This is why Sailop uses a seeded pseudo-random number generator (PRNG) instead of Math.random().

The mulberry32 algorithm is fast, simple, and produces excellent distribution:

function mulberry32(seed: number): () => number {
  return function () {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

To convert a string seed (like a brand name) into a numeric seed, we use a hash function:

function hashString(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash + char) | 0;
  }
  return Math.abs(hash);
}

Now sailop generate --seed "acme-corp" always produces the same design system:

const numericSeed = hashString("acme-corp"); // Always 414029753
const rng = mulberry32(numericSeed);

rng(); // Always 0.7312...
rng(); // Always 0.1893...
rng(); // Always 0.5541...
// Every call produces the same sequence

This is the foundation of reproducible design generation. Every subsequent algorithm uses this RNG, so the entire system is deterministic given the same seed.

Generating a Color Palette

With HSL understanding and a seeded RNG, we can generate palettes. The algorithm:

  • Pick a base hue (avoiding the AI band)
  • Calculate complementary and accent hues
  • Generate shade scales for each hue
  • Ensure WCAG contrast compliance
interface ColorPalette {
  primary: HSLScale;
  secondary: HSLScale;
  accent: HSLScale;
  neutral: HSLScale;
}

interface HSLScale {
  50: string;
  100: string;
  200: string;
  300: string;
  400: string;
  500: string;
  600: string;
  700: string;
  800: string;
  900: string;
  950: string;
}

function generatePalette(rng: () => number): ColorPalette {
  // Step 1: Pick base hue, avoiding the AI band (200-290)
  const baseHue = pickHueOutsideAIBand(rng);

  // Step 2: Calculate related hues
  const complementHue = (baseHue + 150 + rng() * 60) % 360;
  const accentHue = (baseHue + 30 + rng() * 30) % 360;
  const neutralHue = baseHue; // Tinted neutrals for cohesion

  // Step 3: Generate shade scales
  const primary = generateScale(baseHue, 75, rng);
  const secondary = generateScale(complementHue, 60, rng);
  const accent = generateScale(accentHue, 85, rng);
  const neutral = generateScale(neutralHue, 8, rng); // Low saturation

  return { primary, secondary, accent, neutral };
}

function pickHueOutsideAIBand(rng: () => number): number {
  // AI band is 200-290 (90 degrees out of 360)
  // Map RNG to the remaining 270 degrees
  const safeRange = rng() * 270;

  if (safeRange < 200) {
    return safeRange;
  } else {
    // Skip over the 200-290 band
    return safeRange + 90;
  }
}

The pickHueOutsideAIBand function is the simplest piece but arguably the most impactful. By excluding 90 degrees of the color wheel, we eliminate the entire class of "default AI blue" palettes. The generated hue lands on warm reds, oranges, yellows, greens, teals, magentas, or roses -- colors that feel intentional rather than default.

Generating Shade Scales

Each hue needs a full shade scale from 50 (very light) to 950 (very dark):

function generateScale(
  hue: number,
  baseSaturation: number,
  rng: () => number
): HSLScale {
  // Saturation varies slightly across the scale
  // Lighter shades are less saturated, darker shades slightly more
  const satVariance = 5 + rng() * 15;

  const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
  const lightnesses = [97, 93, 86, 76, 64, 50, 40, 32, 24, 17, 10];

  const scale: Record<number, string> = {};

  for (let i = 0; i < shades.length; i++) {
    const shade = shades[i];
    const lightness = lightnesses[i] + (rng() * 4 - 2); // Small jitter
    const saturation = Math.max(
      5,
      baseSaturation - satVariance * (1 - lightness / 50)
    );

    scale[shade] = `hsl(${hue.toFixed(1)}, ${saturation.toFixed(1)}%, ${lightness.toFixed(1)}%)`;
  }

  return scale as unknown as HSLScale;
}

WCAG Contrast Ratio Calculation

A generated palette is useless if it fails accessibility checks. Sailop calculates contrast ratios for every foreground/background combination and adjusts colors that fail WCAG AA (4.5:1 for normal text, 3:1 for large text).

The contrast ratio formula requires relative luminance:

function relativeLuminance(r: number, g: number, b: number): number {
  // Convert sRGB to linear RGB
  const rsRGB = r / 255;
  const gsRGB = g / 255;
  const bsRGB = b / 255;

  const rLinear =
    rsRGB <= 0.03928
      ? rsRGB / 12.92
      : Math.pow((rsRGB + 0.055) / 1.055, 2.4);
  const gLinear =
    gsRGB <= 0.03928
      ? gsRGB / 12.92
      : Math.pow((gsRGB + 0.055) / 1.055, 2.4);
  const bLinear =
    bsRGB <= 0.03928
      ? bsRGB / 12.92
      : Math.pow((bsRGB + 0.055) / 1.055, 2.4);

  return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
}

function contrastRatio(lum1: number, lum2: number): number {
  const lighter = Math.max(lum1, lum2);
  const darker = Math.min(lum1, lum2);
  return (lighter + 0.05) / (darker + 0.05);
}

The ensureContrast loop adjusts lightness until the contrast requirement is met:

function ensureContrast(
  fgHue: number,
  fgSat: number,
  fgLightness: number,
  bgLuminance: number,
  targetRatio: number = 4.5
): number {
  let lightness = fgLightness;
  let attempts = 0;

  while (attempts < 50) {
    const rgb = hslToRgb(fgHue, fgSat, lightness);
    const fgLuminance = relativeLuminance(rgb.r, rgb.g, rgb.b);
    const ratio = contrastRatio(fgLuminance, bgLuminance);

    if (ratio >= targetRatio) {
      return lightness;
    }

    // Determine which direction to adjust
    if (bgLuminance > 0.5) {
      // Light background: make foreground darker
      lightness -= 2;
    } else {
      // Dark background: make foreground lighter
      lightness += 2;
    }

    lightness = Math.max(0, Math.min(100, lightness));
    attempts++;
  }

  // Fallback: return black or white
  return bgLuminance > 0.5 ? 5 : 95;
}

This loop runs during palette generation, ensuring every generated design system is WCAG AA compliant out of the box. No manual checking required. This is one of the advantages procedural generation has over templates -- constraints are baked into the algorithm.

Font Pairing Algorithm

Colors are only half of a design system. Typography matters just as much, and AI tools are just as biased toward Inter as they are toward blue-500. Sailop's font pairing algorithm uses two principles: category contrast and a compatibility matrix.

Category Contrast

Good font pairs typically combine fonts from different categories. A geometric sans-serif heading with a humanist sans-serif body. A serif heading with a sans-serif body. The algorithm enforces this:

type FontCategory =
  | "geometric-sans"
  | "humanist-sans"
  | "neo-grotesque"
  | "slab-serif"
  | "transitional-serif"
  | "old-style-serif"
  | "monospace"
  | "display";

interface FontEntry {
  name: string;
  category: FontCategory;
  weights: number[];
  googleFontsId: string;
}

const fontDatabase: FontEntry[] = [
  // Geometric sans
  { name: "Space Grotesk", category: "geometric-sans", weights: [300, 400, 500, 600, 700], googleFontsId: "Space+Grotesk" },
  { name: "Outfit", category: "geometric-sans", weights: [300, 400, 500, 600, 700], googleFontsId: "Outfit" },
  { name: "Sora", category: "geometric-sans", weights: [300, 400, 500, 600, 700], googleFontsId: "Sora" },
  { name: "General Sans", category: "geometric-sans", weights: [300, 400, 500, 600, 700], googleFontsId: "General+Sans" },

  // Humanist sans
  { name: "Source Sans 3", category: "humanist-sans", weights: [300, 400, 600, 700], googleFontsId: "Source+Sans+3" },
  { name: "Nunito Sans", category: "humanist-sans", weights: [300, 400, 600, 700], googleFontsId: "Nunito+Sans" },
  { name: "Libre Franklin", category: "humanist-sans", weights: [300, 400, 500, 700], googleFontsId: "Libre+Franklin" },

  // Transitional serif
  { name: "Fraunces", category: "transitional-serif", weights: [300, 400, 500, 700, 900], googleFontsId: "Fraunces" },
  { name: "Newsreader", category: "transitional-serif", weights: [300, 400, 500, 700], googleFontsId: "Newsreader" },
  { name: "Instrument Serif", category: "transitional-serif", weights: [400], googleFontsId: "Instrument+Serif" },

  // Old-style serif
  { name: "Crimson Pro", category: "old-style-serif", weights: [300, 400, 600, 700], googleFontsId: "Crimson+Pro" },
  { name: "Cormorant", category: "old-style-serif", weights: [300, 400, 500, 600, 700], googleFontsId: "Cormorant" },

  // Slab serif
  { name: "Roboto Slab", category: "slab-serif", weights: [300, 400, 500, 700], googleFontsId: "Roboto+Slab" },
  { name: "Zilla Slab", category: "slab-serif", weights: [400, 500, 600, 700], googleFontsId: "Zilla+Slab" },

  // Monospace
  { name: "JetBrains Mono", category: "monospace", weights: [400, 500, 700], googleFontsId: "JetBrains+Mono" },
  { name: "IBM Plex Mono", category: "monospace", weights: [300, 400, 500, 700], googleFontsId: "IBM+Plex+Mono" },

  // Display
  { name: "Cabinet Grotesk", category: "display", weights: [400, 500, 700, 800], googleFontsId: "Cabinet+Grotesk" },
  { name: "Clash Display", category: "display", weights: [400, 500, 600, 700], googleFontsId: "Clash+Display" },
];

Compatibility Matrix

Not every category pair works well together. The compatibility matrix encodes typographic wisdom:

const compatibilityMatrix: Record<FontCategory, FontCategory[]> = {
  "geometric-sans":      ["transitional-serif", "old-style-serif", "humanist-sans", "monospace"],
  "humanist-sans":       ["slab-serif", "transitional-serif", "geometric-sans", "display"],
  "neo-grotesque":       ["transitional-serif", "old-style-serif", "slab-serif"],
  "slab-serif":          ["geometric-sans", "humanist-sans", "monospace"],
  "transitional-serif":  ["geometric-sans", "humanist-sans", "monospace"],
  "old-style-serif":     ["geometric-sans", "neo-grotesque", "monospace"],
  "monospace":           ["transitional-serif", "humanist-sans", "geometric-sans"],
  "display":             ["humanist-sans", "transitional-serif", "monospace"],
};

The pairing algorithm picks a heading font, then selects a compatible body font:

interface FontPair {
  heading: FontEntry;
  body: FontEntry;
  mono: FontEntry;
}

function generateFontPair(rng: () => number): FontPair {
  // Pick heading font from any category
  const headingFont = fontDatabase[Math.floor(rng() * fontDatabase.length)];

  // Find compatible body fonts
  const compatibleCategories = compatibilityMatrix[headingFont.category];
  const compatibleFonts = fontDatabase.filter(
    (f) =>
      compatibleCategories.includes(f.category) &&
      f.name !== headingFont.name &&
      f.category !== "monospace" &&
      f.category !== "display"
  );

  // Pick body font from compatible options
  const bodyFont =
    compatibleFonts[Math.floor(rng() * compatibleFonts.length)];

  // Always include a monospace font
  const monoFonts = fontDatabase.filter(
    (f) => f.category === "monospace"
  );
  const monoFont = monoFonts[Math.floor(rng() * monoFonts.length)];

  return { heading: headingFont, body: bodyFont, mono: monoFont };
}

This algorithm never produces Inter + Inter (the most common AI pairing). It always produces a heading/body pair from different categories, ensuring visual contrast. The font database deliberately excludes Inter, Roboto, and other fonts that are overrepresented in AI output.

Modular Spacing Scale

The last piece of a design system is spacing. AI tools default to Tailwind's spacing scale (4px increments), which is fine but uniform. Sailop generates custom spacing scales based on musical ratios:

type SpacingRatio =
  | "minor-second"   // 1.067
  | "major-second"   // 1.125
  | "minor-third"    // 1.200
  | "major-third"    // 1.250
  | "perfect-fourth" // 1.333
  | "aug-fourth"     // 1.414
  | "perfect-fifth"  // 1.500
  | "golden-ratio";  // 1.618

const ratioValues: Record<SpacingRatio, number> = {
  "minor-second":   1.067,
  "major-second":   1.125,
  "minor-third":    1.200,
  "major-third":    1.250,
  "perfect-fourth": 1.333,
  "aug-fourth":     1.414,
  "perfect-fifth":  1.500,
  "golden-ratio":   1.618,
};

interface SpacingScale {
  unit: number;
  ratio: SpacingRatio;
  values: Record<string, string>;
}

function generateSpacingScale(rng: () => number): SpacingScale {
  // Pick a base unit (not always 4px)
  const baseUnits = [3, 4, 5, 6, 8];
  const unit = baseUnits[Math.floor(rng() * baseUnits.length)];

  // Pick a ratio
  const ratios: SpacingRatio[] = Object.keys(ratioValues) as SpacingRatio[];
  const ratio = ratios[Math.floor(rng() * ratios.length)];
  const ratioValue = ratioValues[ratio];

  // Generate scale: 10 steps up and 2 steps down from base
  const values: Record<string, string> = {};
  const steps = [-2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  const names = [
    "3xs", "2xs", "xs", "sm", "md", "lg",
    "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl"
  ];

  for (let i = 0; i < steps.length; i++) {
    const px = Math.round(unit * Math.pow(ratioValue, steps[i]));
    values[names[i]] = `${px}px`;
  }

  return { unit, ratio, values };
}

A golden ratio scale with base 5px produces: 2px, 3px, 5px, 8px, 13px, 21px, 34px, 55px, 89px, 144px, 233px, 377px, 610px. These are essentially Fibonacci numbers, which produce naturally pleasing proportions. Compare this to Tailwind's linear 4, 8, 12, 16, 20, 24... scale where every step feels identical.

Putting It All Together: `sailop generate --seed 42`

Here is what happens when you run sailop generate --seed 42:

$ sailop generate --seed 42

Generating design system with seed: 42
  Numeric seed: 42
  PRNG: mulberry32

Step 1: Color palette
  Base hue: 34.7 (warm amber -- outside AI band)
  Complement: 187.2 (teal)
  Accent: 58.3 (golden yellow)
  Neutral: tinted warm gray
  WCAG AA: all combinations pass (checked 121 pairs)

Step 2: Typography
  Heading: Fraunces (transitional-serif)
  Body: Space Grotesk (geometric-sans)
  Mono: IBM Plex Mono
  Category contrast: serif + sans-serif (excellent)

Step 3: Spacing
  Base unit: 5px
  Ratio: golden-ratio (1.618)
  Scale: 2px / 3px / 5px / 8px / 13px / 21px / 34px / 55px / 89px

Step 4: Border radius
  Strategy: sharp (0px base, 2px for small elements)

Step 5: Shadow strategy
  Strategy: minimal (border-based depth, no box-shadow)

Step 6: Writing CSS custom properties...

Output: .sailop/design-system.css
Output: .sailop/tailwind-preset.js
Output: .sailop/tokens.json

Done. Design system generated in 12ms.
Sailop score estimate: 18/100 (Grade A)

The generated design-system.css contains CSS custom properties:

:root {
  /* Primary (Warm Amber) */
  --color-primary-50: hsl(34.7, 78.2%, 96.8%);
  --color-primary-100: hsl(34.7, 76.1%, 92.4%);
  --color-primary-200: hsl(34.7, 74.3%, 85.1%);
  --color-primary-300: hsl(34.7, 72.8%, 75.2%);
  --color-primary-400: hsl(34.7, 71.5%, 63.7%);
  --color-primary-500: hsl(34.7, 75.0%, 50.0%);
  --color-primary-600: hsl(34.7, 77.2%, 40.3%);
  --color-primary-700: hsl(34.7, 79.1%, 31.8%);
  --color-primary-800: hsl(34.7, 80.4%, 24.2%);
  --color-primary-900: hsl(34.7, 81.6%, 17.5%);
  --color-primary-950: hsl(34.7, 82.8%, 10.1%);

  /* Typography */
  --font-heading: 'Fraunces', serif;
  --font-body: 'Space Grotesk', sans-serif;
  --font-mono: 'IBM Plex Mono', monospace;

  /* Spacing (Golden Ratio, base 5px) */
  --space-3xs: 2px;
  --space-2xs: 3px;
  --space-xs: 5px;
  --space-sm: 8px;
  --space-md: 13px;
  --space-lg: 21px;
  --space-xl: 34px;
  --space-2xl: 55px;
  --space-3xl: 89px;
  --space-4xl: 144px;
  --space-5xl: 233px;

  /* Border radius */
  --radius-sm: 0px;
  --radius-md: 2px;
  --radius-lg: 2px;
  --radius-xl: 4px;

  /* Shadows (minimal -- prefer borders) */
  --shadow-sm: none;
  --shadow-md: 0 1px 2px hsl(34.7, 10%, 80%);
  --shadow-lg: none;
}

Every value in this file was deterministically generated from seed 42. Run the command again with the same seed and you get identical output. Change the seed to 43 and you get an entirely different system (perhaps a deep green palette with slab-serif headings and perfect-fourth spacing).

Extending the Generator

The architecture is modular. You can add new generation dimensions without changing existing ones:

interface DesignSystem {
  palette: ColorPalette;
  fonts: FontPair;
  spacing: SpacingScale;
  radii: RadiusStrategy;
  shadows: ShadowStrategy;
  // Add new dimensions here:
  animations?: AnimationConfig;
  illustrations?: IllustrationStyle;
  iconography?: IconConfig;
}

function generateDesignSystem(seed: string): DesignSystem {
  const rng = mulberry32(hashString(seed));

  return {
    palette: generatePalette(rng),
    fonts: generateFontPair(rng),
    spacing: generateSpacingScale(rng),
    radii: generateRadiusStrategy(rng),
    shadows: generateShadowStrategy(rng),
  };
}

Each generator function consumes RNG values in a fixed order, so adding new dimensions at the end does not change existing ones. This is important for backward compatibility -- a seed that generated a specific palette in version 1.0 should generate the same palette in version 2.0 even if new dimensions were added.

Why This Beats Templates

Templates give you a fixed number of options. ThemeForest has thousands, but they are still a finite, curated set. Seed-based generation gives you 2^32 unique design systems (4.3 billion) from a single algorithm. More importantly, each generated system is internally coherent: the colors, fonts, spacing, and shadows all derive from the same seed and follow the same mathematical relationships.

For a comparison of Sailop's approach versus template marketplaces, read Sailop vs ThemeForest vs Shadcn. For how this integrates into a CI/CD pipeline so every commit is checked against your generated design system, see CI/CD for design.

The code in this article is simplified from Sailop's actual source, but the principles are identical. The real implementation handles edge cases (color blindness simulation, print stylesheets, dark mode generation, RTL spacing adjustments), but the core algorithm is exactly what you see here.

Build your own. Or run sailop generate --seed "your-brand" and get one in 12 milliseconds.

npm install -g sailop
sailop generate --seed "your-brand"

# Explore different seeds:
sailop generate --seed "your-brand" --preview
# Opens browser with live preview of the generated system

Stop using the same design system as everyone else. Start generating your own.

Try Sailop

Scan your frontend for AI patterns. Generate a unique design system. Ship code that looks intentional.

Free scannpm i -g sailop
Share this article
Share on X
Previous
How AI Coding Agents Actually Generate CSS (And Why It's Always the Same)
Next
The State of AI Web Design in 2026: Data from Scanning 1,000 Sites
On this page
Why HSL Is Better Than RGB for GenerationThe AI Band: Hue 200-290Seeded PRNG: Reproducible RandomnessGenerating a Color PaletteGenerating Shade ScalesWCAG Contrast Ratio CalculationFont Pairing AlgorithmCategory ContrastCompatibility MatrixModular Spacing ScalePutting It All Together: `sailop generate --seed 42`Extending the GeneratorWhy This Beats Templates
Sailop 2026All articles