Dark Mode

Noah Hardwicke, Wolfram Research, Inc.

#WolframTechConf

Contents

Approach to dark mode

Premise and technical underpinnings

Experiments in light-to-dark content migration

Migration methodology

Content types and interface segmentation

Color transformations

Examples

Technical Underpinnings

As of 14.3, the Wolfram System has a notion of whether content is displayed in light or dark mode.

"Light mode: bright backgrounds and dark content"

"Dark mode: dark backgrounds and bright content"

Facilitated by:

({{(RefLink[LightDark, paclet:ref/LightDark]), display a notebook in light or dark mode}, {LightDarkSwitched[(Subscript[col, light]), (Subscript[col, dark])], display a different color in light and dark mode}, {ThemeColor["name"], use a named theme color}, {SystemColor ▪ LightDarkAutoColorRules ▪ LightModePane ▪ DarkModePane , }})

Button[
"Examples ↗",
NotebookPut@Notebook[{
Cell[BoxData[RowBox[{"Style","[",RowBox[{"\"WVTC 2025\"",",",RowBox[{"LightDarkSwitched","[",RowBox[{"Blue",",","Green"}],"]"}]}],"]"}]],"Input"],
Cell[BoxData[RowBox[{"Graphics","[",RowBox[{"{","
",RowBox[{RowBox[{"ThemeColor","[","\"Accent1\"","]"}],",",RowBox[{"Disk","[","]"}],",","
",RowBox[{"ThemeColor","[","\"Background\"","]"}],",",RowBox[{"Rectangle","[",RowBox[{"-",RowBox[{"{",RowBox[{".5",",",".5"}],"}"}]}],"]"}]}],"}"}],"]"}]],"Input"]},
Magnification->2],
BaseStyle->"Text"]

Since Wolfram is such a broad system that supports fairly arbitrary content, we need a way to algorithmically migrate content to dark mode.

Light-to-Dark Content Migration Experiments

Although we're working with Wolfram Language and Notebooks, we're really looking for an answer to the general question,

How do I turn a light-mode thing into a dark-mode thing?

Let's begin with some light-mode content:

nbImg=;

This contains varied content:

UI elements (toolbars, dialogues)

Document content (text, code, images, graphics)

Experiment 1: RGB Inversion

Row[{nbImg,ColorNegate[nbImg]}]

Clearly, some content shouldn't be migrated at all (e.g. images)

Inverting all RGB channels leads to unexpected hues

Experiment 2: Invert Lightness Only

Lab

Make use of the Lab color space.

encodes lightness

and  encode chromaticity

Projection of RGB gamut in Lab is not rectilinear:

inGamut=Join[LABColor/@{Black,White},Flatten@Array[With[{lab=LABColor[##]},{rgb=RGBColor[lab]},If[AllTrue[List@@rgb,(0<=#<=1)&],lab,Nothing]]&,{20,20,20},{{.01,.99},{-1.,1.},{-1.,1.}}]];

DynamicModule[
{l=.5},
With[
{gamutMesh=ConvexHullMesh[{#2,#3,#1}&@@@inGamut]},
Row[
{
Graphics3D[
{
GrayLevel[.3],
Text[Style["R","TI",16],{1.35,0,0}],Text[Style["G","TI",16],{0,1.35,0}],Text[Style["B","TI",16],{0,0,1.35+.25}],

Text[Style[{1,0,0},12],{1.35,0,-.3}],Text[Style[{0,1,0},12],{0,1.35,-.3}],Text[Style[{0,0,1},12],{0,0,1.35}],

Arrow[{{0,0,0},{1.2,0,0}}],Arrow[{{0,0,0},{0,1.2,0}}],Arrow[{{0,0,0},{0,0,1.2}}],

Opacity[.85],
EdgeForm[None],
Polygon[{#1,#2,0}&@@@{{0,0},{0,1},{1,1},{1,0}},VertexColors->(RGBColor[#1,#2,0]&@@@{{0,0},{0,1},{1,1},{1,0}})],
Polygon[{#1,0,#2}&@@@{{0,0},{0,1},{1,1},{1,0}},VertexColors->(RGBColor[#1,0,#2]&@@@{{0,0},{0,1},{1,1},{1,0}})],
Polygon[{0,#1,#2}&@@@{{0,0},{0,1},{1,1},{1,0}},VertexColors->(RGBColor[0,#1,#2]&@@@{{0,0},{0,1},{1,1},{1,0}})],
Polygon[{#1,#2,1}&@@@{{0,0},{0,1},{1,1},{1,0}},VertexColors->(RGBColor[#1,#2,1]&@@@{{0,0},{0,1},{1,1},{1,0}})],
Polygon[{#1,1,#2}&@@@{{0,0},{0,1},{1,1},{1,0}},VertexColors->(RGBColor[#1,1,#2]&@@@{{0,0},{0,1},{1,1},{1,0}})],
Polygon[{1,#1,#2}&@@@{{0,0},{0,1},{1,1},{1,0}},VertexColors->(RGBColor[1,#1,#2]&@@@{{0,0},{0,1},{1,1},{1,0}})]
},
Lighting->"Neutral",
Boxed->False,
Axes->False,
ImageSize->150,
ViewPoint->{3Cos[20Degree], 3Sin[20Degree], 2}
],

Style["  -> ",GrayLevel[.5],FontWeight->Bold,24],

Graphics3D[
{
EdgeForm[None],FaceForm[GrayLevel[.7,.1]],gamutMesh,
Dynamic[Polygon[#,VertexColors->(LABColor[#3,#1,#2]&@@@#)]&@@@Replace[
MeshPrimitives[DiscretizeRegion[RegionIntersection[gamutMesh,Polygon[Append[l]/@CirclePoints[2.,4]]],MaxCellMeasure->1],2],
_MeshPrimitives->{}]],
Thickness[.003],FaceForm[GrayLevel[0,1]],Arrowheads[.0175],Arrow@Tube[{{0,0,-.05},{0,0,1.05}}],
Text[Style["L","TI",18],{0,0,1.12}]},
BoxRatios->{1,1,1},
Boxed->False,
Axes->{True,True,False},
AxesLabel->{Style["a","TI",18],Style["b","TI",18],None},
AxesOrigin->{-1,-1,0},
ImageSize->300,
LabelStyle->12],
Spacer[20],
Column[{1,VerticalSlider[Dynamic[l],{0,1,.01}],0},BaseStyle->{"Text",GrayLevel[.4]},Alignment->Center,Spacings->.2],
Spacer[5],
Style["L","TI",GrayLevel[.4],18]}]]]

The bounds of  and  depend on .

Experiment 2: Invert Lightness Only

Scaling  and

In Lab, hues lie roughly along radii in the -plane:

ListPlot[
Function[{l,a,b},{a,b}]@@@Select[Flatten@Array[LABColor@*Hue,{10,30,30},{{0,.9},{0,1},{0,1}}],
Apply[Function[{l,a,b},.6<l<.65]]],]

Hence we can move colours radially in  without incurring significant hue drift.

So, to ensure colors stay in the RGB gamut, we can perform a radial normalisation in the -plane:

Approximate the RGB projection by meshing the Lab space, and discarding points which fall outside of the RGB gamut:

inGamut=Flatten@Join[
LABColor/@{Black,White},Array[With[{lab=LABColor[##]},{rgb=RGBColor[lab]},If[AllTrue[List@@rgb,(0<=#<=1)&],lab,Nothing]]&,{15,15,15},{{0,1},{-1.,1.},{-1.,1.}}]];

ListPointPlot3D[{#2,#3,#1}&@@@inGamut,]

Find the -boundary for a given :

abRegion[l_]:=
RegionBoundary@ConvexHullMesh@Part[
MeshCoordinates@DiscretizeRegion[
RegionIntersection[
ConvexHullMesh[{#2,#3,#1}&@@@inGamut],
Polygon[Append[l]/@CirclePoints[2.,4]]],
MaxCellMeasure->1],
All,;;2]

Manipulate[Show[abRegion[l],],{l,.01,.99},]

Now consider a color to transform (say, ):

LABColor[.55,.4,-.5]

Project the  point radially out to the  boundary, and calculate its normalised radius:

boundaryPt[l_,a_,b_]:=
First@MeshCoordinates@RegionIntersection[
abRegion[l],
Line[{{0,0},2Normalize[{a,b}]}]]

Show[abRegion[.55],[boundaryPt[.55,.4,-.5]],]

rNorm=Norm[{.4,-.5}]/Norm[boundaryPt[.55,.4,-.5]]

Now calculate the boundary radius at the new , using the same azimuth as before:

rNewBoundary=Norm@boundaryPt[.8,.4,-.5]

Scale this by the color's normalised radius to obtain the new color:

newAB=rNewBoundary*rNorm*Normalize[{.4,-.5}]

LABColor@@Prepend[newAB,0.8]

Show[abRegion[.8],[boundaryPt[.8,.4,-.5]],]

Hence, we have a way to reliably adjust the lightness of colors.

Experiment 2: Invert Lightness Only

Invert the lightness of our test notebook:

Row@{#,
ImageApply[
Function[{rgb},List@@RGBColor@NotebookTools`ColorLightnessRemap[
{,}->{,},RGBColor@@rgb,]],
#,
Interleaving->True]
}&@ImageResize[nbImg,Scaled[.5]]

This is better than RGB inversion seeing as hues are more faithfully preserved.

However, we have lost some contrast in the darker tones. This is due to our non-linear perception of lightness:

Framed[
Row[[GrayLevel[#]]&/@Range[0,1,.05]],
FrameMargins->None]

We need to include a gamma correction to get a perceptually uniform lightness progression:

Framed[
Row[[GrayLevel[#^(1/1.4)]]&/@Range[0,1,.05]],
FrameMargins->None]

Now we can try our lightness remap again, with a gamma correction:

Row@{#,
ImageApply[
Function[{rgb},List@@RGBColor@NotebookTools`ColorLightnessRemap[
{,}->{,},RGBColor@@rgb,"Gamma"->1.4,]],
#,
Interleaving->True]
}&@ImageResize[nbImg,Scaled[.5]]

The notebook toolbar is now a noticeably different color than the notebook background (as it is in light mode)

The Image Ribbon now stands out from the notebook background (as it does in light mode)

Final tweak to the remap: brighten the black point to soften overall contrast:

Row@{#,
ImageApply[
Function[{rgb},List@@RGBColor@NotebookTools`ColorLightnessRemap[
{,}->{GrayLevel[0.1],},RGBColor@@rgb,"Gamma"->1.4,]],
#,
Interleaving->True]
}&@ImageResize[nbImg,Scaled[.5]]

Color Types

When migrating arbitrary content, our priority is to preserve legibility (by preserving contrast). A lightness inversion achieves this.

...But for particular types of content, where we know something about the structure and graphic design, can we do better?

Notice how the shadows no longer make physical sense — they are now bright glows, rather than cast shadows.

The interface has been denatured.

Content Types

Content colors

Content colors are those found in document-like or generated content that has been authored into the body of a notebook. For example:

Magnify[Grid[
Transpose[{IntegerName[#],Graphics[{,HilbertCurve[#]}]}&/@Range[4]],
Frame->All,BaseStyle->"Text",FrameStyle->],.6]

We know little about the structure or design. Hence prioritise legibility with a lightness inversion.

DarkModePane[
@Grid[
Transpose[{IntegerName[#],Graphics[{LightDarkSwitched[],HilbertCurve[#]}]}&/@Range[4]],
Frame->All,BaseStyle->"Text",FrameStyle->LightDarkSwitched[]]]

Content Types

Interface colors

Predictable structure and design

Controls and text — features — sit on backgrounds

Avoid denaturing interfaces by preserving brightness relations where possible.

It would have been better to shift the interface backgrounds down in lightness, rather than inverting them.

Text and icons still must be inverted to ensure contrast against backgrounds.

Interfaces in the Wolfram Front End

Elements of the "chrome" that provide structure, navigation, or interactivity beyond the primary authored content of a notebook

Interface Segmentation

Feature, Background and Content Colors

Interface Segmentation

Feature, Background, Mixed and Naturalistic Images

Interface Segmentation

Standard-Contrast and Reverse-Contrast

Interface Segmentation

Dominant Background Colors

The "dominant background color", is the most prevalent background color in an interface's primary state, often light gray or white.

If an interface contains both standard-contrast sections and reverse-contrast sections/elements, define two dominantcolor values: one for all standard-contrast sections and another for all reverse-contrast sections.

Migration Steps

...follow the migration guide!

DarkModeTools paclet releasing shortly (the migration guide's in the paclet).

Two key functions:

ColorToDarkMode

ImageToDarkMode

Also of Note

LightDarkAutoColorRules

Important for backwards compatibility

When LightDarkAutoColorRules is applied to a construct containing colors, any explicit color col in the construct that satisfies one or more of the rules will be displayed as if LightDarkSwitched[col] had instead been used.

LightModePane and DarkModePane

Coming in 15.0 (but versions will be available via the DarkModeTools paclet)

Allow one to display content in light or dark mode regardless of the ambient value of LightDark.

Standard Colors

Colors designed to be suitable for a wide range of uses in both light and dark mode. There are 11 standard colors; here are three of them:

{StandardRed,StandardCyan,StandardPurple}

Talk: User Interface Patterns in Wolfram Language

By Lou D'Andria

Wednesday, November 5 (Today!) 15:30 to 16:00 CT

Key Takeaway

of our dark-mode migration methodology:

When migrating arbitrary content, one should preserve legibility, which is achieved by (gamma-corrected) lightness inversions.

But, by identifying types of content which you expect to have certain structure and design, you can apply more effective and nuanced methods.

Where possible, lightness shifts are preferable over lightness inversions, since these preserve lightness relations and avoid denaturing an interface.

...and I hope you find DarkModeTools helpful!