Skip to content

Color Palette Generator — PHP source

Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php

/**
 * palette-generator — polyglot showcase port (PHP).
 *
 * Pure color-theory helpers for the Color Palette Generator. No I/O, no
 * external dependencies — standard library only. All inputs are clamped; the
 * functions never throw (they fall back to black on bad input).
 *
 * Ported from src/lib/colorPalette.ts (TypeScript, the canonical
 * implementation). This is display source — part of CosmoDev's polyglot tool
 * pages (dev.cosmolabs.org), where each tool's pure logic is shown side-by-side
 * in six languages.
 */

namespace CosmoDev\Palette;

// Scheme identifiers selecting which color-harmony rule generate_palette applies.
const COMPLEMENT       = 'complement';
const SPLIT_COMPLEMENT = 'split-complement';
const ANALOGOUS        = 'analogous';
const TRIADIC          = 'triadic';
const TETRADIC         = 'tetradic';
const MONOCHROMATIC    = 'monochromatic';

/**
 * Parse any reasonable hex string (#rgb / #rrggbb, with or without a leading #)
 * into an [r, g, b] byte array. Unparseable input falls back to black so the
 * generator never throws.
 *
 * @param string $hex
 * @return array{0:int,1:int,2:int}
 */
function hex_to_rgb($hex) {
    $h = preg_replace('/^#/', '', trim((string) $hex));
    if (preg_match('/^[0-9a-fA-F]{3}$/', $h)) {
        // Expand CSS shorthand: each digit doubles (#abc -> #aabbcc).
        $doubled = '';
        foreach (str_split($h) as $c) {
            $doubled .= $c . $c;
        }
        $h = $doubled;
    }
    if (!preg_match('/^[0-9a-fA-F]{6}$/', $h)) {
        return [0, 0, 0];
    }
    return [
        (int) hexdec(substr($h, 0, 2)),
        (int) hexdec(substr($h, 2, 2)),
        (int) hexdec(substr($h, 4, 2)),
    ];
}

/**
 * Round to the nearest integer (half away from zero — matching TypeScript's
 * Math.round on the non-negative color values these helpers produce) and clamp
 * to [0, 255].
 *
 * @param float|int $n
 * @return int
 */
function clamp_byte($n) {
    return (int) min(255, max(0, round($n)));
}

/**
 * Pack three float channels into a lowercase #rrggbb string.
 *
 * @param float|int $r
 * @param float|int $g
 * @param float|int $b
 * @return string
 */
function rgb_to_hex($r, $g, $b) {
    return sprintf('#%02x%02x%02x', clamp_byte($r), clamp_byte($g), clamp_byte($b));
}

/**
 * Convert hex → HSL. h ∈ [0, 360), s/l ∈ [0, 100]. Achromatic colors (gray,
 * white, black) collapse to h = 0, s = 0.
 *
 * @param string $hex
 * @return array{0:float,1:float,2:float}
 */
function hex_to_hsl($hex) {
    [$r8, $g8, $b8] = hex_to_rgb($hex);
    $r = $r8 / 255;
    $g = $g8 / 255;
    $b = $b8 / 255;
    $max = max($r, $g, $b);
    $min = min($r, $g, $b);
    $l = ($max + $min) / 2;
    $h = 0.0;
    $s = 0.0;
    if ($max !== $min) {
        $d = $max - $min;
        $s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min);
        // Which channel is max determines the hue sextant.
        if ($max === $r) {
            $h = ($g - $b) / $d + ($g < $b ? 6 : 0);
        } elseif ($max === $g) {
            $h = ($b - $r) / $d + 2;
        } else {
            $h = ($r - $g) / $d + 4;
        }
        $h /= 6;
    }
    return [$h * 360, $s * 100, $l * 100];
}

/**
 * Convert HSL → hex. h wraps modulo 360 (so h + 150 etc. stay in range); s/l
 * clamp to [0, 100].
 *
 * @param float|int $h
 * @param float|int $s
 * @param float|int $l
 * @return string
 */
function hsl_to_hex($h, $s, $l) {
    // fmod keeps the dividend's sign (like JS %), so add 360 and mod again to
    // land negative rotations in [0, 360).
    $H = fmod(fmod($h, 360) + 360, 360);
    $S = max(0, min(100, $s)) / 100;
    $L = max(0, min(100, $l)) / 100;
    $c = (1 - abs(2 * $L - 1)) * $S;
    $x = $c * (1 - abs(fmod($H / 60, 2) - 1));
    $m = $L - $c / 2;
    if ($H < 60) {
        [$r, $g, $b] = [$c, $x, 0];
    } elseif ($H < 120) {
        [$r, $g, $b] = [$x, $c, 0];
    } elseif ($H < 180) {
        [$r, $g, $b] = [0, $c, $x];
    } elseif ($H < 240) {
        [$r, $g, $b] = [0, $x, $c];
    } elseif ($H < 300) {
        [$r, $g, $b] = [$x, 0, $c];
    } else {
        [$r, $g, $b] = [$c, 0, $x];
    }
    return rgb_to_hex(($r + $m) * 255, ($g + $m) * 255, ($b + $m) * 255);
}

/**
 * Generate a harmonious palette from a base color.
 *
 * Counts: complement = 2, split-complement = 3, analogous = 3, triadic = 3,
 * tetradic = 4. $count is honored by monochromatic (default 5): the base hue
 * and saturation are held while lightness spreads across $count steps.
 *
 * @param string $base_hex
 * @param string $scheme One of the SCHEME_* constants above.
 * @param int    $count
 * @return string[]
 */
function generate_palette($base_hex, $scheme, $count = 5) {
    [$h, $s, $l] = hex_to_hsl($base_hex);
    $base = hsl_to_hex($h, $s, $l);
    // Rotate the hue by $deg degrees, holding saturation and lightness fixed.
    // The closure captures h/s/l by value, matching the TypeScript closure.
    $rot = function ($deg) use ($h, $s, $l) {
        return hsl_to_hex($h + $deg, $s, $l);
    };
    switch ($scheme) {
        case COMPLEMENT:
            return [$base, $rot(180)];
        case SPLIT_COMPLEMENT:
            return [$base, $rot(150), $rot(210)];
        case ANALOGOUS:
            return [$rot(-30), $base, $rot(30)];
        case TRIADIC:
            return [$base, $rot(120), $rot(240)];
        case TETRADIC:
            return [$base, $rot(90), $rot(180), $rot(270)];
        case MONOCHROMATIC:
            $n = (int) max(1, floor($count));
            // Pin the lightness window to [10, 90] so swatches never fully
            // wash out or go black, regardless of the base color's lightness.
            $lo = max(10, $l - 32);
            $hi = min(90, $l + 32);
            $out = [];
            for ($i = 0; $i < $n; $i++) {
                $ll = ($n === 1) ? $l : $lo + (($hi - $lo) * $i) / ($n - 1);
                $out[] = hsl_to_hex($h, $s, $ll);
            }
            return $out;
        default:
            return [$base];
    }
}

/**
 * n shades — the base color mixed progressively toward black (RGB lerp). $i
 * runs 1..n so the base itself is never returned, only intermediate steps.
 *
 * @param string $base_hex
 * @param int    $n
 * @return string[]
 */
function shades($base_hex, $n) {
    [$r, $g, $b] = hex_to_rgb($base_hex);
    $steps = (int) max(1, floor($n));
    $out = [];
    for ($i = 1; $i <= $steps; $i++) {
        $f = $i / ($steps + 1);
        $out[] = rgb_to_hex($r * (1 - $f), $g * (1 - $f), $b * (1 - $f));
    }
    return $out;
}

/**
 * n tints — the base color mixed progressively toward white (RGB lerp). $i
 * runs 1..n so the base itself is never returned.
 *
 * @param string $base_hex
 * @param int    $n
 * @return string[]
 */
function tints($base_hex, $n) {
    [$r, $g, $b] = hex_to_rgb($base_hex);
    $steps = (int) max(1, floor($n));
    $out = [];
    for ($i = 1; $i <= $steps; $i++) {
        $f = $i / ($steps + 1);
        $out[] = rgb_to_hex($r + (255 - $r) * $f, $g + (255 - $g) * $f, $b + (255 - $b) * $f);
    }
    return $out;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →