I subscribed to Discord Nitro a month ago, but only recently did I start thinking about the full range of powers the subscription granted me. I could create my own reactions, dump them in my personal server, and use them to react anywhere.
However, when I finally started trying to create some reactions, I hit an interesting snag: Discord can be used in dark and light mode,1 and a reaction will have the same color on both modes. If I wanted my reaction to be as clearly readable in both modes as possible, what color should I make it?
(I could, of course, just outline my reaction with a contrasting color, but let’s say that’s cheating. With the limited space in a reaction, outlining isn’t that great of a solution anyway.)
Now, one can’t really just compute the “contrast of two colors” given only their RGB components; there’s no universally agreed-on definition of contrast in vision, and even if there were one, the contrast of two given colors would depend on the color space and possibly the viewer’s biology. But, to get a concrete answer to this question, we can use the standard sRGB model and the W3C’s definitions of contrast ratio and relative luminance.2 As of time of writing on my computer,3 Discord reactions have background #2f3136 on dark mode and #f2f3f5 on light mode. Reactions you’ve reacted with have background #3b405a on dark mode and #e7e9fd on light mode. Because the dark mode background gets lighter and the light mode background gets darker, we’ll use the latter colors so we’re optimizing the worst-case contrast.
There are smarter approaches, but the 2563 = 16,777,216 possible 8-bit colors are perfectly feasible to brute force, so I wrote a short Python script to check all of them, which is at the bottom of this post. Under the parameters I’ve outlined, the optimal color for a Discord reaction is rgb(255, 65, 3) or #ff4103. A demo:
#ff4103 #ff4103 ● CR: 2.90738237 |
#ff4103 #ff4103 ● CR: 2.90738217 |
That was simple enough, but this color’s worst-case contrast ratio is less than 0.0000002 better than the runner-up. Surely even very mild aesthetic considerations will outweigh that. (It’s highly doubtful that the formulae I used were intended to have this degree of precision in the first place.)
After playing with a few ways to get a spread of options, I settled on categorizing colors into six buckets of saturation and twelve buckets of hue in the simple HSV model, and then finding the optimal color within each bucket. Here is a table of my results:
#978585 2.90731886 |
#a18373 2.90735283 |
#b57c5e 2.90737786 |
#cf6d60 2.90737862 |
#f64c38 2.90737549 |
#ff4103 2.90738217 |
#8e897a 2.90737477 |
#9d856d 2.90737104 |
#968956 2.90737552 |
#aa824d 2.907379 |
#b87d2d 2.90736999 |
#ca7400 2.90736562 |
#878b7b 2.90728821 |
#7d8f6a 2.90722126 |
#8a8d51 2.90737653 |
#839037 2.90733573 |
#898f1c 2.9073784 |
#829114 2.90737881 |
#818d77 2.90722528 |
#719267 2.90732567 |
#689552 2.90738011 |
#5f9749 2.9073809 |
#419c27 2.9073757 |
#2c9e16 2.90737771 |
#7d8d83 2.90726098 |
#639472 2.90736328 |
#609479 2.90737402 |
#459968 2.90738071 |
#1d9d56 2.90736982 |
#139e47 2.90737571 |
#828b8a 2.90727344 |
#6f9086 2.90737368 |
#5a9485 2.90735873 |
#469782 2.90736751 |
#319982 2.90737959 |
#029a8d 2.90737947 |
#7b8c92 2.90692524 |
#778d92 2.90736748 |
#608eb2 2.90738074 |
#4f92a8 2.90738036 |
#2e979a 2.90738097 |
#1893c4 2.90738057 |
#838a91 2.90732608 |
#7e8a9d 2.90735634 |
#7b83d1 2.90736636 |
#568bd1 2.90737779 |
#448cda 2.90737252 |
#1a88ff 2.90730834 |
#89879c 2.90732849 |
#8d82b7 2.90735461 |
#8480d3 2.90736855 |
#8377fc 2.90737702 |
#a955ff 2.62724564 |
#942aff 1.95183847 |
#92849d 2.90731696 |
#a37baf 2.907341 |
#c068c3 2.90737642 |
#c45ce5 2.90737929 |
#e32df9 2.90737724 |
#e919fc 2.90738152 |
#9c8290 2.90734675 |
#b17898 2.90737112 |
#c566bd 2.90738078 |
#d55fa5 2.90738203 |
#e647be 2.90738136 |
#f013ec 2.90737953 |
#9c8387 2.9071286 |
#af7a90 2.90733165 |
#cc6993 2.90737618 |
#d76097 2.90738093 |
#fb3982 2.90737999 |
#ff2a94 2.90671668 |
Note the lower contrast ratios in some of the highly saturated purple cells: red and blue don’t contribute enough to luminance to produce enough contrast from the dark background. Some of them are actually so low that pure black contrasts more with the dark background (and obviously far more with the light background).
Personally, I like the color #f64c38 right next to the color I identified as “optimal”. It’s still pretty saturated, but in an image the size of a Discord reaction, I find it tolerable.
Discord Roles
The exact same methodology can be adapted to find the optimal color of a Discord role. This is a question that has been investigated before, from a source that I found both unexpected and completely unsurprising: the Society for Internet Blaseball Research published a report titled Mutually Arising: Improving accessibility in Discord team role color contrast (PDF). Unlike them, though, I’m not constrained to following any existing colors or having to contrast different role colors, so I can just brute-force the entire color space again.
In dark mode, the main chat area’s background color is #36393f and is lighter than the sidebar color, but in light mode, the sidebar’s background color #f2f3f5 is darker than the main chat area’s background color. Colored usernames are displayed in the main chat and the sidebar, so we’ll use those two colors.
#908581 3.22885521 |
#9f8172 3.22913619 |
#cd6a68 3.2291472 |
#b47b44 3.22915923 |
#cd6d40 3.2291583 |
#c77221 3.22915821 |
#898783 3.22898816 |
#92866e 3.22911463 |
#a28254 3.22914074 |
#8e8a3f 3.22915361 |
#97881f 3.22915935 |
#bb7913 3.22915934 |
#838a75 3.22915533 |
#818c61 3.22913777 |
#848c53 3.22912369 |
#6c9332 3.22908373 |
#73921c 3.22914719 |
#888d01 3.22911826 |
#7f8b76 3.22901735 |
#7d8c70 3.22909715 |
#768f5d 3.22913547 |
#3a9a37 3.22915844 |
#339b26 3.22914616 |
#159d06 3.22915524 |
#7b8b82 3.22901303 |
#718e7b 3.22913699 |
#5b9462 3.22907559 |
#399a3a 3.22915831 |
#2c9a54 3.22913751 |
#039d28 3.22915326 |
#7d8a87 3.22912012 |
#698f83 3.22912601 |
#4b938d 3.22913981 |
#41948f 3.22913438 |
#1a9975 3.22914554 |
#0b9b5c 3.22915786 |
#808989 3.22914971 |
#728c8e 3.22915953 |
#5b8ea5 3.22913831 |
#548eae 3.22913265 |
#23959d 3.22915726 |
#228ae7 3.22915974 |
#84869a 3.22891109 |
#7688a8 3.22910931 |
#7581d5 3.22915501 |
#5888d1 3.22915032 |
#3084fe 3.22914904 |
#1f85ff 3.22913804 |
#878789 3.22898604 |
#967db0 3.22908146 |
#9a72e0 3.22914979 |
#9172ef 3.22913814 |
#a955ff 2.99380305 |
#942aff 2.22416202 |
#918299 3.22897811 |
#a17aa7 3.22911498 |
#b569cc 3.22915756 |
#cb4feb 3.22915243 |
#d146f1 3.22915316 |
#e910f5 3.22913484 |
#998092 3.22909464 |
#ad75a4 3.22909518 |
#b46fb2 3.22915634 |
#d15ab7 3.22915698 |
#e740bc 3.22915923 |
#fd05b4 3.22915035 |
#958384 3.22899029 |
#a67b90 3.22915584 |
#c86893 3.229159 |
#d7626d 3.22915995 |
#f14a57 3.22914762 |
#fe2390 3.22915422 |
Here’s the optimal color, apparently:
#d7626d #d7626d ● CR: 3.22916018 |
#d7626d #d7626d ● CR: 3.22915995 |
Interactive
Type an arbitrary CSS color to visually see it against these different backgrounds.
Discord Dark, main chat ● | Discord Light, main chat ● |
Discord Dark, sidebar or inactive reaction ● | Discord Light, sidebar or inactive reaction ● |
Discord Dark, active reaction ● | Discord Light, active reaction ● |
There are a bajillion tools to automatically compute contrast ratios online. Two I like are WebAIM’s Contrast Checker and Lea Verou’s contrast-ratio.com.
Appendix: my Python script
Not the best code I’ve written, but not the worst either. Polished only enough that I’m okay publishing it. I tweaked the script after generating the above tables so it also considers colors that are darker than both or lighter than both target colors, which I think generalizes better in a sense.
from typing import List, Tuple, Optional
# https://www.w3.org/TR/WCAG20-TECHS/G17.html
def luminance_component(c0: int) -> float:
c = c0 / 255
# https://github.com/w3c/wcag/issues/360#issuecomment-498615230
if c <= 0.04045: # or 0.03928, this literally doesn't matter for 8-bit colors
return c / 12.92
else:
return ((c + 0.055) / 1.055) ** 2.4
def luminance(r: int, g: int, b: int) -> float:
R, G, B = map(luminance_component, (r, g, b))
return 0.2126 * R + 0.7152 * G + 0.0722 * B
Color = Tuple[int, int, int]
def contrast_ratio(rgb1: Color, rgb2: Color) -> float:
lum1 = luminance(*rgb1)
lum2 = luminance(*rgb2)
return (max(lum1, lum2) + 0.05) / (min(lum1, lum2) + 0.05)
def worst_case_ratio(c1: Color, c2: Color, cb: Color) -> float:
"The worse ratio between c1:cb and c2:cb."
return min(contrast_ratio(c1, cb), contrast_ratio(c2, cb))
def report(light: Color, dark: Color) -> None:
print(light, dark, contrast_ratio(light, dark))
def hue(r: int, g: int, b: int) -> float:
mx = max(r, g, b)
c = max(r, g, b) - min(r, g, b)
if c == 0: return 0 # whatever
if mx == r: return ((g - b) / c) % 6
elif mx == g: return ((b - r) / c) + 2
else: return ((r - g) / c) + 4
def saturation(r: int, g: int, b: int) -> float:
mx = max(r, g, b)
if mx == 0: return 0
return 1 - min(r, g, b) / mx
def solve(color1: Color, color2: Color) -> None:
saturation_buckets = 6
hue_buckets = 12
# Table of (score, color)
buckets: List[List[Optional[Tuple[float, Color]]]] = [[None] * saturation_buckets for _ in range(hue_buckets)]
best_score = 0.0
for r in range(0, 256):
for g in range(0, 256):
for b in range(0, 256):
color = (r, g, b)
score = worst_case_ratio(color1, color2, color)
hi = int(hue(r, g, b) / 6 * hue_buckets)
si = min(saturation_buckets - 1, int(saturation(r, g, b) * saturation_buckets))
cb = buckets[hi][si]
if cb is None or score > cb[0]:
buckets[hi][si] = (score, color)
best_score = max(best_score, score)
print("Best colors for contrast between", color1, "and", color2)
print("<table>")
for cs in buckets:
print("<tr>")
for c in cs:
if c is None:
print("<td>n/a</td>")
continue
score, color = c
r, g, b = color
txt = "#{:02x}{:02x}{:02x}<br><small>{:.9}</small>".format(r, g, b, score)
if score == best_score:
txt = "<strong>{}</strong>".format(txt)
print('<td style="background-color: rgb{}">{}</td>'.format(color, txt))
print("</tr>")
print("</table>")
discord_dark_bg = (0x36, 0x39, 0x3f)
discord_dark_sidebar_bg = (0x2f, 0x31, 0x36)
discord_dark_react_bg = (0x2f, 0x31, 0x36)
discord_dark_reacted_bg = (0x3b, 0x40, 0x5a)
discord_light_bg = (0xfa, 0xfa, 0xfa)
discord_light_sidebar_bg = (0xf2, 0xf3, 0xf5)
discord_light_react_bg = (0xf2, 0xf3, 0xf5)
discord_light_reacted_bg = (0xe7, 0xe9, 0xfd)
solve(discord_dark_reacted_bg, discord_light_reacted_bg)
solve(discord_dark_bg, discord_light_sidebar_bg)
Apparently, it also has a hidden AMOLED mode available on Android only.↩
There is some debate over the constant 0.03928: apparently it’s outdated and the new constant should be 0.04045. However, this has literally zero effect on any color with a six-hex-digit code. It only affects a tiny fraction of colors on systems that represent color components with 10 or more bits. That said, there are real systems with that much color depth.↩
I would be surprised if this varied a lot between devices, but not surprised if it slightly varied. According to the Electron inspector, the reacted-reaction background on dark mode is composed from a color with some alpha atop another color. I could be being A/B-tested at any point, or there might be device/OS-specific tweaks. And, of course, this is all liable to change in future Discord redesigns.↩