DEV Community

Cover image for A palette wasn't enough, so I built a color system generator
David Foliti
David Foliti

Posted on

A palette wasn't enough, so I built a color system generator

I wanted a workflow that sounded straightforward: start with a color and turn it into something I could use in a frontend project.

There are already plenty of good palette generators. I wasn't interested in building another tool that stops after producing eleven attractive swatches. For me, that's where the actual work begins.

Which shade should drive the main action? What happens to it in dark mode? Which foreground works on top of it? And does the palette still hold together once it reaches buttons, cards, borders, charts and larger surfaces?

I started exploring those questions in my spare time. That experiment eventually became Tintary, a free color-system generator with no account or sign-up.

A palette is source material, not the final result

A 50–950 scale is useful:

50  100  200  300  400  500  600  700  800  900  950
Enter fullscreen mode Exit fullscreen mode

But applications rarely think only in terms of purple-600 or blue-200. They also need roles:

primary
surface
surface-muted
foreground
border
Enter fullscreen mode Exit fullscreen mode

That distinction became central to Tintary. Raw scales preserve the available color range; semantic roles explain how those colors should be used. A role can resolve differently in light and dark mode without changing the underlying source palette.

Instead of treating a palette as finished output, Tintary treats it as material for a small, reusable color system.

Tintary raw scale and semantic color roles

Making eleven shades behave like one family

I underestimated scale generation at first. Moving toward white and black produces eleven colors, but not necessarily eleven useful steps.

Tintary uses Culori for parsing, conversion, color distance and contrast calculations. The scale recipe follows the public approach used by UIColors: for chromatic scales, it compares the source against Tailwind v3 reference families using CIEDE2000, finds the closest progression, then adapts that progression around the input color.

The source isn't automatically forced into the 500 slot. Tintary places it at the stop with the closest lightness, so an input may become 400, 500 or 600. The remaining shades grow around it, and every result is available as HEX, RGB, HSL and OKLCH.

Large semantic surfaces need different treatment from raw shade stops. Tintary generates those through a separate, bounded OKLCH recipe so light and dark surfaces keep a useful depth order without becoming overwhelmingly chromatic.

The conversion code wasn't the difficult part. The difficult part was preserving relationships: a very light shade should work as a surface, middle shades should remain useful for actions, and dark shades should still feel connected to the original color.

The foreground problem

Foreground selection looked simple until I compared the numbers with what I preferred on screen.

Take #ED002F as a background. Using literal black and white for a small example, WCAG contrast works out to roughly:

  • black: 4.64:1
  • white: 4.52:1

Black wins, but only narrowly. Both pass WCAG AA for normal text; neither reaches AAA. Side by side, I sometimes still prefer white on this particular red.

That led me to keep two ideas separate:

Contrast is measurable. Readability still needs to be inspected in context.

Tintary supports three kinds of foreground rule: a Y/luma heuristic, maximum WCAG contrast, and a guarded strategy that keeps the heuristic choice only when it reaches a configured threshold.

The Check stage then evaluates the resulting foreground/background pairs independently against the active policy, WCAG AA and WCAG AAA. Choosing a foreground and validating its contrast are related operations, but they are not the same operation.

I didn't want the tool to hide that distinction behind one generic “accessible” badge. Showing the ratio, threshold and affected role makes the trade-off much easier to understand.

Colors need consequences

This is the part of Tintary I use most now.

A row of swatches can look balanced while hiding practical problems. Once applied to an interface, the same palette may reveal that:

  • the primary button dominates everything around it;
  • the muted surface isn't muted enough;
  • borders disappear in one mode;
  • chart series are difficult to distinguish;
  • a gradient that looked good in isolation feels terrible behind content.

Tintary's Preview stage applies the generated system to interface patterns, component states and more visual compositions. It isn't trying to simulate every possible product. It gives colors enough context to expose weak relationships early.

Palette Lab preview using three source colors

Open this three-color setup in Palette Lab.

One color model, five export adapters

I wanted Tintary's internal model to remain independent from any CSS framework. Tailwind is used to build the app's interface, but it doesn't define the generated theme.

Tintary currently exports the same color system in five formats:

  • generic CSS custom properties;
  • Tailwind CSS v4 @theme;
  • Material UI theme configuration;
  • Bootstrap 5.3 variables;
  • Figma / Tokens Studio JSON.

Each exporter acts as an adapter. It consumes the same generated theme and formats it for a target; it doesn't regenerate or reinterpret the colors.

For example, the same scale can become CSS:

:root {
  --color-primary-50: ...;
  --color-primary-500: ...;
  --color-primary-950: ...;
}
Enter fullscreen mode Exit fullscreen mode

or Tailwind v4 output:

@theme {
  --color-primary-50: ...;
  --color-primary-500: ...;
  --color-primary-950: ...;
}
Enter fullscreen mode Exit fullscreen mode

If I stop using Tailwind tomorrow, the color model shouldn't care.

Why I kept the workspace local

Tintary has no authentication, backend or cloud palette database. Workspace settings and saved themes stay in the browser through localStorage.

Source colors live in the URL path, while non-color workspace settings can travel in a versioned URL hash. That means a setup can be bookmarked or shared without creating an account:

tintary.app/colors/e544c5/1b90ee/e1890f/preview/palette-lab

You can also save named theme versions in the browser, or download a versioned JSON snapshot and import it again later. I wanted portability, but I didn't want registration to become the price of saving three colors.

Deciding where to stop

Tintary is intentionally color-only. It doesn't generate typography scales, spacing systems, shadows, radius tokens, motion tokens or complete UI components.

It also doesn't try to become a full design-system platform. Its workflow has a narrower boundary:

Build → Preview → Check → Export
Enter fullscreen mode Exit fullscreen mode

You bring source colors, generate the system, inspect it in context, check foreground and contrast decisions, then export it. That boundary keeps the tool useful without turning it into a large visual editor.

Keeping the engine independent

Tintary is built with Next.js, TypeScript, Tailwind CSS, shadcn/ui, Culori and Zustand. Vitest, Playwright and axe-core cover unit, browser and accessibility checks.

The architectural flow is roughly:

Source colors
     ↓
Color engine
     ↓
Raw 50–950 scales
     ↓
Semantic roles
     ↓
Preview / Check
     ↓
Export adapters
Enter fullscreen mode Exit fullscreen mode

Color generation lives in pure functions, separate from Zustand editor state and framework exporters. That choice has made new export targets easier to add and, more importantly, prevents framework-specific assumptions from leaking back into the theme.

What building Tintary changed for me

I started with the idea that palette generation was the main problem. It wasn't. The more interesting questions came afterwards: how much should the tool decide, which decisions should remain visible, and where should accessibility checks inform the workflow without pretending to replace design judgment?

Tintary's answer is to keep one framework-agnostic model, offer explicit foreground policies, and report contrast results without silently rewriting the user's colors.

That isn't the only valid approach, but it is the boundary that currently makes sense to me.

Try Tintary

Tintary is free to use, has no paid tier and doesn't require an account. The codebase is private.

I built it because I wanted this workflow for my own frontend projects. It's still evolving, and bugs, criticism and suggestions are welcome.

If you try it, I'd love to know one thing: is the workflow clear and useful for real frontend work, and where could the experience be improved?

Top comments (2)

Collapse
 
amitfeldman profile image
Amit Feldman

Going from "a palette" to a full 50–950 system with contrast evaluation is the right scope — congrats on the launch.

Quick public check of tintary.app (headers + public config only):

  1. HSTS is set (max-age=63072000 — Vercel's default, good). But Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy are all unset. Since you're on Vercel/Next.js, the clean fix is a headers() block in next.config (or vercel.json) — no middleware needed, ~10 lines. CSP is the one to approach carefully: Next's prerendered pages tolerate it well, but add it in report-only mode first if you have inline scripts.
  2. One small on-page item: your single image (the hero, presumably) is missing alt text — one attribute, free accessibility and image-search signal.

TLS 1.3, title, meta description, single h1, canonical, robots.txt and sitemap.xml all check out, and TTFB is 100ms. Happy to re-run the scan free once the headers land. Good luck with Tintary!

Collapse
 
damianociarla profile image
Damiano Ciarla

Really thoughtful work. I especially like the distinction between a palette as source material and a color system as something that expresses actual roles and consequences.

The Build → Preview → Check → Export workflow makes a lot of sense, and keeping the core model framework-agnostic is a strong architectural choice. Also great to see accessibility treated transparently rather than reduced to a generic accessible badge.

Congrats on building and sharing Tintary — definitely worth trying for anyone working on frontend or design systems!