<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: adousa</title>
    <description>The latest articles on DEV Community by adousa (@adousa).</description>
    <link>https://dev.to/adousa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F334844%2F6dbcdf44-2ad6-46a2-9627-6c30614a10e6.jpeg</url>
      <title>DEV Community: adousa</title>
      <link>https://dev.to/adousa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adousa"/>
    <language>en</language>
    <item>
      <title>Picking black or white text: a tiny trained model vs WCAG luminance</title>
      <dc:creator>adousa</dc:creator>
      <pubDate>Sat, 23 May 2026 19:30:25 +0000</pubDate>
      <link>https://dev.to/adousa/picking-black-or-white-text-a-tiny-trained-model-vs-wcag-luminance-36ac</link>
      <guid>https://dev.to/adousa/picking-black-or-white-text-a-tiny-trained-model-vs-wcag-luminance-36ac</guid>
      <description>&lt;p&gt;If your UI lets users pick their own colors — tags, labels, calendar events, avatars generated from a username — you've eventually written this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;backgroundColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;anythingUserPicked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;textColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isDark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;white&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;black&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The textbook answer for the right side is the W3C's relative luminance formula from &lt;a href="https://www.w3.org/TR/WCAG20/#relativeluminancedef" rel="noopener noreferrer"&gt;WCAG 2.0 §1.4.3&lt;/a&gt;: convert sRGB to linear light, weight by &lt;code&gt;0.2126·R + 0.7152·G + 0.0722·B&lt;/code&gt;, threshold at &lt;code&gt;0.179&lt;/code&gt;. It's principled. It's the de facto standard. It's also wrong about &lt;strong&gt;one in seven colors&lt;/strong&gt; when you ask actual humans.&lt;/p&gt;

&lt;p&gt;This post is about a four-number alternative — three coefficients and an intercept — that gets the same job done with higher agreement with human judgment, smaller bytecode, and one transcendental call instead of three.&lt;/p&gt;

&lt;p&gt;I'm not claiming to replace WCAG. WCAG measures &lt;strong&gt;contrast for low-vision users&lt;/strong&gt;; this picks &lt;strong&gt;text color for typical readers&lt;/strong&gt;. Different problems, related answers, very different priorities. With that out of the way:&lt;/p&gt;

&lt;h2&gt;
  
  
  The training data
&lt;/h2&gt;

&lt;p&gt;The model is a binary logistic regression fit on &lt;strong&gt;just over 600 hand-labeled colors&lt;/strong&gt;. Each sample is an &lt;code&gt;(r, g, b)&lt;/code&gt; triple paired with &lt;code&gt;0&lt;/code&gt; (use white text) or &lt;code&gt;1&lt;/code&gt; (use black text), assigned by a human looking at a swatch and deciding which text looked more readable. The dataset is roughly balanced — about half "white-text", half "black-text".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"backgroundColor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"r"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;84&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"g"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;179&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;73&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"textColor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a small dataset by ML standards, but the task is also small — the decision surface lives in a 3-dimensional space (RGB), and the boundary is genuinely close to a plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;Fit a plain logistic regression on the raw RGB triples (sklearn defaults, 90/10 train/test split). The result is four numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COEFFICIENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.027291&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0688366&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.006275&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INTERCEPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;13.9369834&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the runtime function is one line of arithmetic, one &lt;code&gt;exp&lt;/code&gt;, and a compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isLightText&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.027291&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.0688366&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.006275&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;13.9369834&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns &lt;code&gt;true&lt;/code&gt; if white text wins on that background. ~80 bytes of logic. No string parsing, no branches, no gamma curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the coefficients are telling you
&lt;/h2&gt;

&lt;p&gt;Look at the relative weights: green ≈ 0.069, red ≈ 0.027, blue ≈ 0.006. Almost exactly the same &lt;strong&gt;ordering&lt;/strong&gt; as WCAG's perceptual weights (&lt;code&gt;0.2126·R + 0.7152·G + 0.0722·B&lt;/code&gt;). The model rediscovered, from scratch, that green contributes most to perceived brightness, red contributes moderately, blue contributes least. Without telling it anything about vision science.&lt;/p&gt;

&lt;p&gt;The ratio is different, though — the model says green matters ~2.5× more than red, where WCAG says ~3.4×. And it underweights blue more aggressively. That's where the disagreements live.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it compares to WCAG on this dataset
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Accuracy on labeled set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WCAG relative luminance&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;83.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isLightText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two algorithms &lt;strong&gt;disagree on roughly 14.5% of colors&lt;/strong&gt; (89 out of ~600). On those disagreements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;isLightText&lt;/code&gt; matched the human label &lt;strong&gt;72 times&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;WCAG matched &lt;strong&gt;17 times&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, when these two methods give you different answers, the trained model is right four times out of five.&lt;/p&gt;

&lt;p&gt;A caveat I want to flag honestly: those numbers are on the same labeled set used to fit the coefficients. The model was trained on 90% of it, but the comparison above counts the whole set. The 14.5% disagreement rate and the 4:1 ratio on disagreements are the more robust takeaways than the headline accuracy gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it disagrees — concrete cases
&lt;/h2&gt;

&lt;p&gt;These are colors where the two algorithms pick different text colors. Open them in any color tool to judge for yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cases the trained model gets right:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Color&lt;/th&gt;
&lt;th&gt;RGB&lt;/th&gt;
&lt;th&gt;Human label&lt;/th&gt;
&lt;th&gt;&lt;code&gt;isLightText&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;WCAG&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#0b9cd5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(11, 156, 213)&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;white ✓&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#1ba3f5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(27, 163, 245)&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;white ✓&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#ec4a89&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(236, 74, 137)&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;white ✓&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#d15952&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(209, 89, 82)&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;white ✓&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#209165&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(32, 145, 101)&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;white ✓&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#8c7e06&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(140, 126, 6)&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;white ✓&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are exactly the colors that look unambiguously "dark enough for white text" to a human, but sit just above WCAG's &lt;code&gt;0.179&lt;/code&gt; threshold. Saturated mid-luminance blues, pinks, reds, olives — colors with one dominant channel pushing them up the WCAG scale without actually making them feel light.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cases WCAG gets right and the model misses:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Color&lt;/th&gt;
&lt;th&gt;RGB&lt;/th&gt;
&lt;th&gt;Human label&lt;/th&gt;
&lt;th&gt;&lt;code&gt;isLightText&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;WCAG&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#fb50e0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(251, 80, 224)&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;black ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#f9492d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(249, 73, 45)&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;black ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#e53af1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(229, 58, 241)&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;td&gt;white&lt;/td&gt;
&lt;td&gt;black ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model's failures are concentrated in the &lt;strong&gt;saturated magenta/pink/orange&lt;/strong&gt; corner. It tends to read these as darker than they are. Honestly, opinions vary on these — &lt;code&gt;#f9492d&lt;/code&gt; is the kind of color where reasonable people will argue for either text color.&lt;/p&gt;

&lt;h2&gt;
  
  
  A geometric way to think about it
&lt;/h2&gt;

&lt;p&gt;A logistic regression on raw RGB is just a &lt;strong&gt;plane&lt;/strong&gt; through 3D color space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0273·R + 0.0688·G + 0.0063·B = 13.9370
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything below the plane → white text. Everything above → black text. The probability score is just how far you are from the plane, squashed into [0, 1] by the sigmoid.&lt;/p&gt;

&lt;p&gt;WCAG's luminance threshold is a &lt;strong&gt;surface&lt;/strong&gt; through the same space, but a curved one because of the gamma decoding step (&lt;code&gt;pow((v+0.055)/1.055, 2.4)&lt;/code&gt;). It's a more sophisticated boundary, which is why it dominates on textbook colors — but it was designed for &lt;strong&gt;contrast measurement&lt;/strong&gt;, not for the binary text-color decision. Optimizing the wrong objective gets you a more elegant surface for a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-side demo
&lt;/h2&gt;

&lt;p&gt;If you want to feel where the two algorithms diverge, I built an interactive comparison:&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;&lt;a href="https://adousa.github.io/is-light-text/compare-with-luminance.html" rel="noopener noreferrer"&gt;demo: isLightText vs W3C luminance&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drag the picker around. Both decision boundaries are drawn directly on the saturation/brightness plane. As you change hue, you'll see them slide past each other — wide gaps in the cyan/orange ranges, near-overlap in true greys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;This is a UI primitive that gets called a lot — once per chip, badge, swatch, table cell, generated avatar, syntax-highlighted token. So the cost matters a little.&lt;/p&gt;

&lt;p&gt;Per call:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Multiplies&lt;/th&gt;
&lt;th&gt;Adds&lt;/th&gt;
&lt;th&gt;Branches&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;exp&lt;/code&gt;/&lt;code&gt;pow&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WCAG&lt;/td&gt;
&lt;td&gt;~6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;up to 3&lt;/td&gt;
&lt;td&gt;each &lt;code&gt;pow&lt;/code&gt; is &lt;code&gt;exp(2.4·log(x))&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isLightText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;linear scan + one sigmoid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly &lt;strong&gt;3× fewer transcendental calls&lt;/strong&gt;. Both finish in microseconds, so this only matters if you're rendering tens of thousands of swatches in a tight loop — but if you are, &lt;code&gt;isLightText&lt;/code&gt; is the right pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not an accessibility tool.&lt;/strong&gt; WCAG measures contrast ratios for low-vision users; this picks between two pre-chosen text colors for typical readers. If you need to pass an audit, use WCAG (or &lt;a href="https://github.com/Myndex/apca-w3" rel="noopener noreferrer"&gt;APCA&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can be wrong.&lt;/strong&gt; Saturated magentas and oranges (&lt;code&gt;#f9492d&lt;/code&gt;, &lt;code&gt;#fb50e0&lt;/code&gt;) sit in a region where humans themselves disagree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The model is published as a tiny package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;black-or-white-text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isLightText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;black-or-white-text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;isLightText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true  — use white text on black&lt;/span&gt;
&lt;span class="nf"&gt;isLightText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// false — use black text on white&lt;/span&gt;
&lt;span class="nf"&gt;isLightText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#0b9cd5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true  — the case WCAG misses&lt;/span&gt;
&lt;span class="nf"&gt;isLightText&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// true  — RGB tuple input works too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;demo:&lt;/strong&gt; &lt;a href="https://adousa.github.io/is-light-text/compare-with-luminance.html" rel="noopener noreferrer"&gt;adousa.github.io/is-light-text/compare-with-luminance.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;code&gt;[black-or-white-text](https://www.npmjs.com/package/black-or-white-text)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;source + data:&lt;/strong&gt; &lt;a href="https://github.com/adousa/is-light-text" rel="noopener noreferrer"&gt;github.com/adousa/is-light-text&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find a color where it picks wrong, file an issue with the hex and what you expected. That's exactly the kind of feedback that grows the labeled dataset and improves the next set of coefficients.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>a11y</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
