How to Convert HEX to HSL and Generate WCAG 2.1 Compliant Color Palettes In-browser

Stop Generating Palettes by Sliding HSL Lightness Like a Volume Knob

Here's the scene I've watched play out a hundred times. A developer picks a brand color—say #4A90D9—converts it to HSL, gets hsl(211, 65%, 57%), and then generates a "palette" by creating steps at 10%, 20%, 30%... all the way up to 90% lightness. They drop these swatches into a design system, run a quick visual check, ship it, and three weeks later the accessibility audit comes back with a wall of contrast failures.

The mistake isn't the conversion itself. It's the assumption that HSL lightness maps to perceptual brightness in a way that guarantees WCAG 2.1 contrast ratios. It doesn't. And if you're building in-browser color tools—theme editors, palette generators, design-token pipelines—this assumption will quietly poison every palette you produce.

Let me walk through the specific errors I see in nearly every HEX-to-HSL palette workflow, and how to fix each one so your generated palettes pass WCAG 2.1 AA (and AAA where it matters) on the first audit.

Mistake 1: Treating HSL Lightness as Perceptual Luminance

HSL is a geometric model. It was designed in the 1970s to be computationally cheap and intuitive for humans picking colors on a color wheel. Lightness in HSL is defined as the average of the max and min RGB channel values—nothing more.

The problem? Human vision doesn't perceive brightness as an RGB average. A pure yellow at hsl(60, 100%, 50%) feels significantly brighter than a pure blue at hsl(240, 100%, 50%), yet both have the same HSL lightness. This means if you generate palette steps by evenly incrementing HSL lightness, your yellow ramp will look blown out in the mid-tones while your blue ramp looks muddy.

Why This Breaks WCAG Compliance

WCAG 2.1 contrast ratios are calculated using relative luminance, which is a weighted sum of linearized RGB channels. The formula gives green roughly 71.5% of the weight, red 21.3%, and blue just 7.2%. HSL lightness has no relationship to this weighting at all.

Concrete example: #4A90D9 converts to hsl(211, 65%, 57%). At 57% HSL lightness, you might assume this color is "medium" and will contrast well with both white and black text. Let's check.

The relative luminance of #4A90D9 is approximately 0.272. Against white (luminance 1.0), the contrast ratio is:

(1.0 + 0.05) / (0.272 + 0.05) = 3.26:1

That fails WCAG 2.1 AA for normal text, which requires 4.5:1. It only passes for large text (3:1). So your "medium" blue button with white text? Non-compliant. And you'd never know by looking at the HSL lightness value alone.

Mistake 2: Generating Palette Steps Before Checking Contrast

The typical workflow I see in browser-based color tools goes like this: convert HEX to HSL, generate 9 or 10 steps by adjusting lightness in fixed increments, then optionally run a contrast check at the end. If a step fails, the developer either deletes it or nudges the lightness manually until it passes.

This is backwards. You're generating swatches and then hoping they comply. By the time you audit, you've already built a palette structure around values that don't work.

Fix: Let Contrast Ratios Drive the Palette Structure

Instead of picking lightness values and checking contrast afterward, decide your target contrast ratios first, then solve for the HSL values that produce them. Here's how I do it in practice:

  1. Fix the hue and saturation from your brand color's HSL conversion.
  2. For each palette step, define a target WCAG contrast ratio against a known background (usually white #FFFFFF or near-black #1A1A1A).
  3. Binary-search the HSL lightness value until the generated color hits the target ratio.

For a text-color palette on white, I target these ratios: 7:1 (darkest, AAA), 4.5:1 (AA), 3:1 (large text / UI borders), and 1.5:1 (muted / disabled states). This gives you a palette where every step has a documented compliance level, not a guess.

The binary search is trivial in-browser. You convert your candidate HSL back to RGB, compute relative luminance, check the ratio, and narrow the lightness range. It converges in about 15-20 iterations—instantaneous on any modern device.

Mistake 3: Using a Single Background for All Contrast Checks

Most in-browser palette generators check contrast against pure white (#FFFFFF) and call it done. But real interfaces use multiple surfaces: cards on light-gray backgrounds, dark mode panels, hover states with tinted backgrounds. A color that passes 4.5:1 against white might drop below threshold when placed on a #F5F5F5 card surface.

Fix: Test Against Your Actual Surface Palette

When I build a palette generator, I define the background surface set first—typically three or four values representing the lightest, default, elevated, and dark-mode surfaces. Every generated foreground color gets checked against all of them, and the tool reports the minimum ratio across the set.

This matters most for mid-tone colors. #4A90D9 against white gives 3.26:1. Against #F5F5F5 (luminance ~0.913), the ratio drops to roughly 2.98:1. That's below even the 3:1 large-text threshold. If your UI places blue links on light-gray card backgrounds, you've got a failure that a white-only check would never catch.

Mistake 4: Ignoring Saturation's Effect on Luminance

Here's one that catches even experienced developers. When you generate a palette by holding saturation constant and adjusting only lightness, the relative luminance doesn't change linearly across the ramp. High saturation amplifies the luminance difference between hues—so a saturated yellow ramp and a saturated blue ramp with identical HSL lightness steps will have wildly different luminance distributions.

Fix: Adjust Saturation Per Step

In practice, I reduce saturation as lightness moves toward the extremes. Dark colors can hold more saturation without breaking contrast (they're already luminance-heavy on the dark side). Light colors need reduced saturation to maintain perceptual distinction between steps without washing out.

A pragmatic approach: start with your brand color's HSL saturation, then reduce it by roughly 3-5% per step as you move toward the light end, and increase it by 1-2% per step as you move toward the dark end. This isn't a formula from a spec—it's a habit born from generating hundreds of palettes and noticing that flat-saturation ramps always look wrong at the extremes.

Mistake 5: Not Converting Back to HEX for Final Output

This sounds trivial, but I've seen production design systems where color tokens are stored as HSL strings because the palette generator never converted back. HSL is great for generation and manipulation. It's terrible for storage and interoperability.

CSS custom properties accept HSL, but every browser dev tool, design tool, and accessibility checker expects HEX or RGB. If your palette tool outputs hsl(211, 65%, 27%) instead of #1B4A7A, you're creating friction for every downstream consumer.

Fix: Always Round-Trip to HEX

The conversion from HSL back to HEX is straightforward. For hsl(211, 65%, 27%):

  1. C = (1 - |2 × 0.27 - 1|) × 0.65 = 0.46 × 0.65 = 0.299
  2. X = 0.299 × (1 - |((211 / 60) mod 2) - 1|) = 0.299 × 0.483 = 0.144
  3. m = 0.27 - 0.299/2 = 0.120
  4. Since 180 ≤ 211 < 240: R' = X, G' = C, B' = 0
  5. R = (0.144 + 0.120) × 255 = 67, G = (0.299 + 0.120) × 255 = 107, B = (0 + 0.120) × 255 = 31
  6. HEX: #436B1F... wait, let me recheck. Actually with H=211, that's in the 180-240 range where R'=X, G'=C, B'=0. So R = (0.144 + 0.120) × 255 ≈ 67, G = (0.299 + 0.120) × 255 ≈ 107, B = (0 + 0.120) × 255 ≈ 31. That gives #436B1F.
Wait—that doesn't look right for a darkened version of #4A90D9. The hue should stay at 211, which is blue. Let me recalculate. At H=211, which falls in the 180-240 range, the assignment is R'=X, G'=C, B'=0. But that gives a green-dominant color, which is wrong for hue 211 (blue). Let me correct: for H in [180, 240), the correct assignment is R'=0, G'=X, B'=C. So:
  • R = (0 + 0.120) × 255 ≈ 31
  • G = (0.144 + 0.120) × 255 ≈ 67
  • B = (0.299 + 0.120) × 255 ≈ 107
  • HEX: #1F436B
  • That's a dark blue—exactly what you'd expect from darkening #4A90D9. And its luminance is approximately 0.045, giving a contrast ratio against white of about 11.7:1. Well above AAA. The point stands: always round-trip back to HEX, and verify the round-trip by recomputing luminance from the final RGB values. If your conversion math is off, the luminance check will catch it.

    The Workflow That Actually Works

    Here's the process I've settled on after building palette tools for multiple design systems:

    Step 1: Convert and Decompose

    Take the input HEX, convert to HSL, and extract hue and saturation as your anchors. These stay roughly constant across the palette—hue absolutely, saturation with the gradual adjustment described above.

    Step 2: Define Your Contrast Targets

    Map out what each palette step is for. A typical mapping:

    • Step 900 (darkest): 7:1+ against white — primary text, AAA
    • Step 700: 4.5:1 against white — body text, AA
    • Step 500 (brand): original color, used for large UI elements only
    • Step 300: 3:1 against white — borders, dividers, large text
    • Step 100 (lightest): 1.5:1 against white — muted backgrounds, disabled states

    Step 3: Binary-Search Lightness for Each Step

    For each target ratio, binary-search the HSL lightness that produces a color meeting that ratio against your surface palette. This is 10-20 lines of JavaScript and runs in microseconds.

    Step 4: Verify and Round-Trip

    Convert each generated HSL value back to HEX, recompute luminance from the RGB values, and confirm the contrast ratio holds. This catches any floating-point drift or conversion bugs.

    Step 5: Generate the Dark-Mode Variant

    Repeat the process against your dark surface (e.g., #1A1A2E

    You'll typically need to reduce saturation by 10-15% and shift the hue 5-10° toward blue for dark-mode variants. This compensates for the way dark backgrounds perceptually amplify warm hues—a saturated orange that looks vibrant on white can appear muddy or even harsh on a dark surface. The shift is subtle but makes the difference between a dark mode that feels intentional and one that feels like a CSS filter was slapped on.

    Step 6: Export Tokens in Multiple Formats

    Once your palette is verified, export it in the formats your team actually consumes. I output three files from every generator run: a CSS custom properties file with HEX values, a JSON token file for design tooling, and a TypeScript constants file for application code. The HEX values are canonical—everything else derives from them. This avoids the HSL-string problem entirely while still giving developers the format they prefer for their stack.

    Verifying WCAG 2.1 Compliance Programmatically

    The last piece is making compliance verification repeatable. Don't rely on manual checks with browser extensions or external contrast checkers. Build the verification into the generator itself so every palette is validated at generation time.

    The WCAG 2.1 contrast ratio formula is:

    (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter color's relative luminance and L2 is the darker's. Relative luminance is computed from sRGB values with the standard gamma correction: linearize each channel (R/255, then apply the piecewise function), then combine as 0.2126 × R' + 0.7152 × G' + 0.0722 × B'.

    For each generated color, I check it against every surface in the defined set and log:

    • The minimum ratio across all surfaces
    • Which surface produced that minimum
    • Whether it passes AA (4.5:1 for normal text, 3:1 for large text), AA Large (3:1), or AAA (7:1)
    • A warning if a color passes against the default surface but fails against an elevated or alternate surface

    This log becomes part of the palette's documentation. When a designer asks "can I use step 500 for body text on the card surface?", the answer is in the verification output—not a guess or a quick manual check that might miss an edge case.

    Handling Non-Text Contrast (WCAG 2.1 Success Criterion 1.4.11)

    One area where palette generators consistently fall short: non-text contrast. WCAG 2.1 Success Criterion 1.4.11 requires a 3:1 ratio for UI components and graphical objects—borders, icons, focus indicators, form field boundaries. Many tools only check text contrast ratios and declare a palette compliant.

    If your palette includes colors used for borders, input borders, or icon strokes, those need separate verification. A step 300 color that passes 3:1 for large text might be used as a border on a tinted background where it drops below 3:1. The fix is the same as with text: define the use cases, define the surfaces, and check every color against every surface it will actually appear on.

    I add a "non-text" flag to palette steps designated for UI components. The verification logic uses the 3:1 threshold for those steps instead of 4.5:1, and it checks them against the surfaces where borders and icons actually appear—typically the default and elevated surfaces, not pure white.

    The Bottom Line

    Generating WCAG 2.1 compliant color palettes in-browser isn't hard because the math is complex—it isn't. The conversion formulas are well-documented, the luminance calculation is straightforward, and the contrast ratio is a single division. It's hard because most tools take shortcuts: checking against white only, holding saturation flat, inverting lightness for dark mode, skipping the round-trip verification.

    The workflow that works: convert HEX to HSL, anchor hue and saturation, binary-search lightness against a defined surface palette, adjust saturation per step, round-trip back to HEX, verify luminance from the final RGB, and document the results. Do that and your generated palettes will be compliant, usable across light and dark modes, and interoperable with every tool in your stack.

    The in-browser part matters because it makes all of this instant and transparent. No server round-trips, no opaque API calls—just JavaScript running the same formulas you can inspect and audit. When a compliance question comes up in a design review, you can open the generator, point at the verification output, and answer with a ratio and a surface name. That's the level of rigor WCAG 2.1 deserves, and it's achievable in a few hundred lines of code.

    Frequently Asked Questions

    How do I convert HEX to HSL?

    To convert a HEX color to HSL, you first translate the hex values into their RGB equivalents. Then, you apply a mathematical formula to normalize these RGB values to find the Hue, Saturation, and Lightness percentages. Our in-browser tool automates this entire process instantly without requiring any server-side processing.

    What is the WCAG 2.1 color contrast requirement?

    WCAG 2.1 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text to meet Level AA accessibility standards. For Level AAA, the strictest standard, normal text requires a 7:1 ratio and large text requires 4.5:1. Using an HSL-based palette generator helps you easily adjust lightness to meet these exact thresholds.

    How can I generate a WCAG compliant color palette from a single HEX code?

    Start by converting your base HEX code into HSL, which makes it easier to create perceptually uniform shades and tints. Next, test the contrast ratios of these generated variations against white or black backgrounds to ensure they meet WCAG 2.1 standards. Our browser-based tool automatically calculates these ratios as you generate your palette.

    Why use HSL instead of HEX for generating accessible colors?

    HSL (Hue, Saturation, Lightness) separates the lightness component from the color's hue and saturation, making it incredibly intuitive for creating color variations. By simply adjusting the Lightness percentage, you can systematically generate darker or lighter shades that safely pass WCAG contrast guidelines. HEX codes, being a blend of red, green, and blue values, do not offer this straightforward visual relationship.

    Can I convert HEX to HSL and check contrast directly in my browser?

    Yes, modern web browsers can easily handle HEX to HSL conversions and WCAG contrast calculations using client-side JavaScript. This means your data never leaves your computer, ensuring complete privacy and instant results. Simply input your HEX code into our tool, and it will generate the HSL values and compliance metrics locally.

    What is a good contrast ratio for accessible web design?

    A good contrast ratio for accessible web design depends on the text size, but aiming for at least 4.5:1 ensures compliance with WCAG 2.1 AA standards for normal text. If you are designing for Level AAA compliance, you should target a 7:1 contrast ratio. Generating an HSL palette allows you to precisely tweak lightness until you hit your target ratio.

    How do I calculate the color contrast ratio between two HEX colors?

    To calculate the contrast ratio, convert both HEX colors to their RGB values and determine their relative luminance. The contrast ratio is then calculated by dividing the lighter luminance by the darker luminance, adding 0.05 to each value before dividing. Our tool performs this complex math instantly in your browser as you build your palette.

    Does WCAG 2.1 apply to background colors and UI components?

    Yes, WCAG 2.1 Success Criterion 1.4.11 states that user interface components and graphical objects must have a contrast ratio of at least 3:1 against adjacent colors. This ensures that buttons, borders, and focus indicators are clearly visible to users with visual impairments. Converting your UI colors to HSL makes it easy to adjust lightness slightly to pass this 3:1 threshold.

    What are the best colors for accessibility?

    The best colors for accessibility are those that provide a stark contrast in luminance, such as dark text on a light background or light text on a dark background. Avoid relying solely on color to convey information, as colorblind users may miss important context. Using an HSL palette generator helps you maintain consistent branding while ensuring your color choices meet WCAG standards.