Building a Design System Generator from Scratch
A from-scratch procedural design system generator in TypeScript: seeded mulberry32 PRNG, an HSL palette that dodges the 200-290 "AI band," WCAG-enforced shade scales, and golden-ratio spacing. Real code, real numbers, no templates.
Run sailop generate --seed 42 and 12 milliseconds later you have a warm amber palette (hue 34.7), Fraunces over Space Grotesk, a golden-ratio spacing scale, and 121 WCAG-checked color pairs. Run it again with seed 42 and you get the exact same system, byte for byte. Change it to 43 and you get a deep-green slab-serif system instead. No template was involved. This article shows you how to build that generator from scratch in TypeScript.
The premise: template marketplaces sell you a finite set of choices, AI agents hand you the *same* choices for free, and neither produces a system that is actually yours. Procedural generation is the third path — an algorithm plus a seed value yields an unbounded supply of unique, internally coherent design systems. That is how Sailop works under the hood.
For the conceptual overview, read procedural design systems and how Sailop generates infinite palettes. For the problem this solves, see why every AI-generated website looks the same. This article is pure code.
Why HSL Beats RGB for Generation
RGB is the model most developers know, and it is useless for generation. Nothing about #3B82F6 tells you it is a medium-saturated blue, and nudging a single channel produces unpredictable shifts. You cannot walk RGB space and land on a coherent palette.
HSL maps each dimension to a perceptual quality:
- Hue (0-360): the color itself. 0 red, 120 green, 240 blue.
- Saturation (0-100%): vividness. 0% gray, 100% pure color.
- Lightness (0-100%): 0% black, 50% the pure color, 100% white.
HSL Color Wheel (simplified):
0/360 = Red
|
330 | 30
\ | /
\ | /
300 ------ + ------ 60
Magenta | Yellow
/ | \
/ | \
270 | 90
Blue | Green
\ | /
\ | /
240 --- + --- 120
210 150
CyanNow palette generation is just arithmetic: pick a base hue, derive complementary, analogous, and accent hues with angular offsets, then sweep saturation and lightness for shade scales. Every result stays perceptually coherent because the math tracks human color perception directly.
The AI Band: Hue 200-290
Before generating colors, decide what to avoid. Across thousands of AI-generated sites, the hues cluster hard in the 200-290 range — the AI band, the blue-indigo-purple zone that defines the tailwind-blue look.
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 excludes the AI band outright. That single decision moves the needle more than anything else: a warm amber palette (hue ~35) reads as a brand decision, while blue-600 (hue ~220) reads as a default nobody chose.
Seeded PRNG: Reproducible Randomness
You need randomness for variety and reproducibility at the same time. Generate a system today, run the same command tomorrow, get the same result. That rules out Math.random() and rules in a seeded pseudo-random number generator.
mulberry32 is fast, fits in seven lines, and distributes well:
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;
};
}A string seed (a brand name) becomes a numeric seed through a hash:
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" is fully deterministic:
const numericSeed = hashString("acme-corp"); // Always 1895044003
const rng = mulberry32(numericSeed);
rng(); // Always 0.8379...
rng(); // Always 0.0951...
rng(); // Always 0.0732...
// Same seed, same sequence, every machine, every runThis is the foundation. Every algorithm downstream pulls from this single rng, so the whole system is deterministic given one seed.
Generating a Color Palette
With HSL and a seeded RNG in hand:
- Pick a base hue outside the AI band
- Derive complement and accent hues
- Generate a shade scale for each
- Enforce WCAG contrast
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;
}
}pickHueOutsideAIBand is the smallest function here and the one that does the most work. Excise 90 degrees of the wheel and the entire class of default-AI-blue palettes becomes unreachable. What lands instead is amber, rust, olive, teal, magenta, rose — hues that feel chosen.
Generating Shade Scales
Each hue needs a full scale from 50 (near white) to 950 (near black):
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;
}The rng() * 4 - 2 jitter on lightness is deliberate. Perfectly even lightness steps (the 50/100/200... Tailwind cadence) are themselves an AI tell — see the 23 signs of AI-generated code. A few tenths of a percent of variance keeps the scale machine-coherent but human-irregular.
WCAG Contrast, Enforced
A palette that fails accessibility is a bug, not a style. Sailop computes the contrast ratio for every foreground/background pair and bends any failing color until it clears WCAG AA (4.5:1 for body text, 3:1 for large text).
Contrast needs 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);
}ensureContrast walks lightness toward the target ratio:
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 runs during generation, so every system ships WCAG AA compliant with zero manual checking. That is the structural advantage of generation over templates: the constraint lives in the algorithm, not in a designer's discipline.
Font Pairing Algorithm
Color is half a design system; type is the other half, and AI tools are just as biased toward Inter as they are toward blue-500. Sailop's pairing runs on two rules: category contrast and a compatibility matrix.
Category Contrast
Strong pairs combine fonts from different categories — a geometric-sans heading over a humanist-sans body, a serif heading over a sans body. The algorithm enforces it:
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" },
];Note what is missing: no Inter, no Roboto, no Geist. Those fonts are so overrepresented in AI output they now function as a fingerprint, the way Geist became the new Inter. Excluding them from the database means the generator physically cannot produce the default look.
Compatibility Matrix
Not every category pair sits well together. The matrix encodes the 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"],
};Pick a heading font, then 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 };
}The result never collapses to Inter + Inter, the most common AI pairing. It always returns a heading and body from contrasting categories.
Modular Spacing Scale
Last piece: spacing. AI tools fall back to Tailwind's 4px-increment scale — fine, but every gap feels the same. Sailop builds custom scales from 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, 90px, 145px, 235px, 380px, 615px. The first eight steps are exactly Fibonacci because φ generates the Fibonacci sequence; past that, Math.round accumulates a little drift (90 instead of 89, 145 instead of 144), which is harmless for layout and quietly de-machines the larger values. Either way it beats Tailwind's flat 4/8/12/16/20/24 march, where every step lands the same distance apart.
Putting It Together: `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 / 90px
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 emitted design-system.css is just 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: 90px;
--space-4xl: 145px;
--space-5xl: 235px;
/* 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 here was derived from seed 42. Rerun with 42, get this file again. Switch to 43, get a different system end to end — say a deep-green palette with slab-serif headings and perfect-fourth spacing.
Extending the Generator
The architecture is modular — add a dimension without touching the 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),
};
}One rule keeps this stable: each generator consumes RNG values in a fixed order, so new dimensions go on the *end*. Add animations before palette and every existing seed silently produces a different palette — the same seed that gave a client amber in v1 ships them magenta in v2, and you will not get a bug report, you will get an angry email. Append-only ordering is the contract that makes seeds durable across versions.
Why This Beats Templates
ThemeForest has thousands of themes — a large but finite, curated set. A 32-bit seed gives you 2^32 systems, roughly 4.3 billion, from one algorithm. And each one is internally coherent: colors, fonts, spacing, and shadows all fall out of the same seed under the same math, which is exactly the case templates can't make. To wire the generated system into a pipeline so every commit is checked against it, see CI/CD for design.
The code above is simplified from Sailop's source, but the principles are identical. The production version handles the edge cases — color-blindness simulation, print stylesheets, dark-mode generation, RTL spacing — while the core algorithm is exactly what you just read.
Build your own. Or run it:
npx sailop install
sailop generate --seed "your-brand"
# Explore different seeds:
sailop generate --seed "your-brand" --preview
# Opens browser with live preview of the generated systemStop shipping the same design system as everyone else. Generate your own.
SHIP CODE THAT LOOKS INTENTIONAL
Scan your frontend for AI patterns. Generate a unique design system. Stop shipping the same blue gradient as everyone else.