facebook / stylex

StyleX is the styling system for ambitious user interfaces.
https://stylexjs.com
MIT License
8.41k stars 310 forks source link

Support dark mode by class #122

Closed olivierpascal closed 10 months ago

olivierpascal commented 11 months ago

Is your feature request related to a problem? Please describe.

My app set dark mode with <html data-mode="dark">. I don't think there is a way to use stylex to generate a css style like :is([data-mode="dark"] .myclass) { ... }.

Describe a solution you'd like Describe alternatives you've considered Additional context

timwehrle commented 11 months ago

You can have a look at the Next.js example to see how it might be done. But I don't think it works like you want to make it work in styleX.

I don't know if this is your question or not, but try to be explicit about what you want support for. Or describe the solution you're looking for.

olivierpascal commented 11 months ago

Of course: for the needs of my app, i need to be able to demo a component in both light and dark mode on the same screen.

<div data-mode="light">
  <MyStyledComponent className="myComponent" />
</div>

<div data-mode="dark">
  <MyStyledComponent className="myComponent" />
</div>

I cannot use @media (prefers-color-scheme: light/dark) because this is OS specific (unaffected by the color-scheme css property).

I would like to be able to generate a style like:

:root {
  --primary: red;
}

:is([data-mode="dark"]) {
  --primary: blue;
}

.myComponent {
  color: var(--primary);
}

Maybe from something like:

// tokens.stylex.js

import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  primary: {
    default: 'red',
    ':is[dark-mode="dark"]': 'blue',
  },
});
// MyComponent.jsx

import { colors } from './tokens.stylex';

const styles = stylex.create({
  myComponent: {
    color: colors.primary,
  },
});

// ...

What do you think?

olivierpascal commented 11 months ago

Or, to generate a style like:

:root {
  --primary: red;
  --primary-dark: blue;
}

.myComponent {
  color: var(--primary);
}

:is([data-mode="dark"]) .myComponent {
  color: var(--primary-dark);
}

Maybe from something like:

// tokens.stylex.js

import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  primary: 'red',
  'primary-dark': 'blue',
});
// MyComponent.jsx

import { colors } from './tokens.stylex';

const styles = stylex.create({
  myComponent: {
    color: {
      default: colors.primary,
      ':is[dark-mode="dark"]': colors['primary-dark'],
    }
  },
});

// ...
olivierpascal commented 11 months ago

I know this syntax would conflict with pseudo classes, but you get the idea.

purohitdheeraj commented 11 months ago

hey @olivierpascal how about using the required component as nested object psuedoSelector(dark).myComponent : colorValue psuedoSelector(light).myComponent : colorValue

for this there is a reference on docs, see if it helps defining styles

correct me if it doesn't relate to your usecase

olivierpascal commented 11 months ago

Mmmh, the problem is that the pseudo selector :is([dark-mode="dark"]) should be at the root level and hence placed before the class name, like :is([data-mode="dark"]) .myComponent {} or :is([data-mode="dark"] .myComponent) {}.

I don't think it's possible to do so with a nested object, at the component level.

olivierpascal commented 11 months ago

I think for my use case, the best API would be something like:

// tokens.stylex.js

import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  primary: {
    default: 'red',
    ':is[dark-mode="dark"]': 'blue',
  },
  secondary: {
    default: 'green',
    ':is[dark-mode="dark"]': 'black',
  },
});

-or-

// tokens.stylex.js

import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  default: {
    primary: 'red',
    secondary: 'green',
  },
  ':is[dark-mode="dark"]': {
    primary: 'blue',
    secondary: 'dark',
  },
});

to generate:

/* stylex.css */

:root {
  --primary: red;
  --secondary: green;
}

:is([data-mode="dark"]) {
  --primary: blue;
  --secondary: black;
}

The remaining question would still be how to do the same at the component level, without the theming API.

olivierpascal commented 11 months ago

Sorry to spam. I just see that [https://stylexjs.com]() stylesheet is compiling to this:

:root {
    --ifm-color-primary: #872291;
    /* ... */
}

html[data-theme=dark]:root {
    --ifm-color-primary: #be51c8;
    /* ... */
}

I don't know if it uses stylex or pure css. Probably a mix of both.

nmn commented 11 months ago

There's a lot here already, so I'll try to just answer in the initial question. Using dark mode based on a user controlled data-id. This can be done by creating default variables with defineVars and then creating separate themes with createTheme.

Something like:

// vars.stylex.js
import * as stylex from '@stylexjs/stylex';

export const colors = stylex.defineVars({
  primary: 'red',
  // ...
});
// color-themes.js
import * as stylex from '@stylexjs/stylex';
import {colors} from './vars.stylex.js';

export const darkTheme = stylex.createTheme(colors, {
  primary: 'blue',
  // ...
});
// layout.tsx

import {darkTheme} from './color-themes';

export default function RootLayout({isDarkMode, children}) {
  return (
    <html {...stylex.props(isDarkMode && darkTheme)}>
      <head>{/* ... */}</head>
      <body>
        {children}
      </body>
    </html>
  );
}

Now you can just import and use the variables from vars.stylex.ts directly and use them. They will resolve to light mode or not based on whether the theme is set on the html tag or not.


Things to note:


If you want a "light" theme, a "dark" theme, and a default theme that uses @media to change automatically you can do that too. Change the original variables defined with defineVars to use media queries, then create two themes for light and dark that don't use media queries.

kevintyj commented 11 months ago

Just to add on to the conversation, you can also use a Context (if you are using React or Nextjs) to switch between themes globally similar to most other styling libraries out there

const ThemeContext = React.createContext(undefined)
...
const [theme, setTheme] = useState('light');
...
// You can also append props to children directly through cloneElement (but is discouraged by React team)
<ThemeContext.Provider value={ theme }>
    <body {...props(theme === 'dark' && darkTheme)}>
        {children}
    </body>
</ThemeContext.Provider>

You can also use custom hooks and an effect to automatically set default theme and persist data to local storage. React docs: https://react.dev/reference/react/createContext shows the same exact application method. This way you would not have to create one defineVars and two separate themes to use it with @media, you can just create the same defineVars with an added theme as mentioned above by @nmn, but this may not be a recommended use pattern (and why he did not cover it).

nmn commented 11 months ago

I didn't cover the JS bits but @kevintyj's suggestion is valid for React.

This way you would not have to create one defineVars and two separate themes to use it with @media

I'm not clear what this means so I'll try to clarify what I meant by multiple themes.

Case 1: Light Theme is default. Dark theme can be enabled manually

Use defineVars to create the default light theme, use createTheme to create a dark theme. Apply dark theme conditionally as suggested by @kevintyj

Case 2: System theme is default. Light and dark themes can be enabled manually

Use defineVars and use the media query to make this theme adapt to the media query. Use createTheme to create the "light" and "dark" themes that always use the light or dark colors respectively.

olivierpascal commented 11 months ago

@nmn your solution works like a charm, thank you. I have nothing to add, maybe we can close this topic? Thanks all very much.

doiya46 commented 10 months ago

@nmn If we possess numerous properties but wish to override certain values, what would be the most effective method to accomplish this? I used ...colors.__tokens but it didn't work, so I have to export base values.

Here is the code I've written, but I think it's not quite right.

// tokens.stylex.tsx
import * as stylex from '@stylexjs/stylex';
const DARK = '@media (prefers-color-scheme: dark)';

export const colorsRepo = {
  primaryText: { default: 'black', [DARK]: 'white' },
  secondaryText: { default: '#333', [DARK]: '#ccc' },
  accent: { default: 'blue', [DARK]: 'lightblue' },
  background: { default: 'white', [DARK]: 'black' },
  lineColor: { default: 'gray', [DARK]: 'lightgray' },
  borderRadius: '4px',
  fontFamily: 'system-ui, sans-serif',
  fontSize: '16px',
};

export const colors = stylex.defineVars(colorsRepo);

export const spacing = stylex.defineVars({
  none: '0px',
  xsmall: '4px',
  small: '8px',
  medium: '12px',
  large: '20px',
  xlarge: '32px',
  xxlarge: '48px',
  xxxlarge: '96px',
});
//themes.ts
import * as stylex from '@stylexjs/stylex';
import { colors, colorsRepo } from './tokens.stylex';

// A constant can be used to avoid repeating the media query
const DARK = '@media (prefers-color-scheme: dark)';

// Dracula theme
export const dracula = stylex.createTheme(colors, {
  ...colorsRepo,
  primaryText: { default: 'purple', [DARK]: 'lightpurple' },
  background: { default: '#555', [DARK]: 'black' },
});
// MyComponent.tsx
'use client';

import * as stylex from '@stylexjs/stylex';
import { colors, spacing } from './tokens.stylex';
import { dracula } from './themes';
import { useState } from 'react';

const styles = stylex.create({
  container: {
    color: colors.primaryText,
    backgroundColor: colors.background,
    padding: spacing.medium,
    marginTop: '10px',
    fontSize: colors.fontSize,
  },
});

export const MyComponent = ({ children }: any) => {
  const [isDark, setDark] = useState(false);
  const toggle = () => {
    setDark(!isDark);
  };
  return (
    <div {...stylex.props(isDark && dracula)}>
      <div {...stylex.props(styles.container)}>
        <button onClick={toggle}>Toggle</button>
        &nbsp;{children}
      </div>
    </div>
  );
};

And one more thing, the class for debug mode is UnknownFile__dracula

I test with https://github.com/facebook/stylex/tree/main/apps/nextjs-example)

<div class="x1wm1iiy UnknownFile__dracula">
  <button>Toggle</button>
    <div class="MyComponent__styles.container x13ca1vq x1lfge5 x1nq940n xf0e2mi">Hello
  </div>
</div>
Screenshot 2023-12-21 at 21 58 16
olivierpascal commented 10 months ago

I had this bug the very first time I used stylex (with some code straight from the doc) but, as I stopped using media queries, I forgot to create an issue.

On my side, the following does work:

export const colorsRepo = {
  background: 'red',
};
export const colorsRepo = {
  background: {
    default: 'red', // work
    whatever: 'lightgray',
  },
};

When the following silently fail (the entire var group is not set):

export const colorsRepo = {
  background: {
    default: 'red',
    '@media (prefers-color-scheme: dark)': 'lightgray',
  },
};

If you can reproduce this bug, maybe create a new issue with a minimal reproducible code?

nmn commented 10 months ago

If we possess numerous properties but wish to override certain values, what would be the most effective method to accomplish this?

This was a type constraint that I fixed yesterday. In the next version, you'll be able to override only some of the variables when creating a theme.

@olivierpascal Could you give me a slightly more detailed example?

olivierpascal commented 10 months ago

Ok @nmn, here is a full reproducible example:

// tokens.stylex.ts

import * as stylex from '@stylexjs/stylex';

// A constant can be used to avoid repeating the media query
const DARK = '@media (prefers-color-scheme: dark)';

export const colors = stylex.defineVars({
  primary: { default: 'blue', [DARK]: 'green' }, // NOT OK: text output is black
  // primary: 'blue', // OK: text output is blue
  // primary: { default: 'blue', whatever: 'green' }, // OK: text output is blue
});
// MyComponent.tsx

import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { colors } from './tokens.stylex';

const styles = stylex.create({
  container: {
    color: colors.primary,
  },
});

export const MyComponent: React.FC = () => (
  <div {...stylex.props(styles.container)}>TEXT</div>
);

The CSS var is not compiled.

image
olivierpascal commented 10 months ago

Probably related to https://github.com/facebook/stylex/issues/235

nmn commented 10 months ago

@olivierpascal Yup, I think this is caused by #235 as well. Can you try a production build to verify in your case as well?

nmn commented 10 months ago

v0.4.1 Fixes the issues mentioned.

alejandroyunes commented 10 months ago

for me this is working both with context and prefers-color-scheme.

//themes.ts
const DARK = '@media (prefers-color-scheme: dark)';

export const lightTheme = stylex.createTheme(colors, {
  primary: 'blue',
  bg: {
    default: "white",
    [DARK]: "white",
  }
})

export const darkTheme = stylex.createTheme(colors, {
  primary: 'blue',
  bg: {
    default: "black",
    [DARK]: "black",
  }
})

how can i make it work without the colors object, If possible. I couldn't get the red or blue to appear both changing the prefers-color-scheme though the browser and by changing the theme via context.

export const colors = stylex.defineVars({
  primary: 'red',
  bg: {
    default: "red",
    [DARK]: "blue",
  }
});

then i made this provider like suggested above:

//providers.tsx
type Theme = 'light' | 'dark'

interface ThemeContextType {
  theme: Theme
  setTheme: Dispatch<SetStateAction<Theme>>
}
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export const ThemeProvider = ({ children }: Props) => {

  const [theme, setTheme] = useState<Theme>('dark')
  const contextValue: ThemeContextType = { theme, setTheme }

  return (
    <ThemeContext.Provider value={contextValue}>
      <html {...stylex.props(s.html, s.reset)} lang="en">
        <body {...stylex.props(s.reset, s.body, theme === 'dark' ? darkTheme : lightTheme)}>
          {children}
        </body>
      </html>
    </ThemeContext.Provider>
  )
}

export const useTheme = () => {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider')
  }
  return context
}
//component.ts
  const { setTheme } = useTheme();
//layout.ts
export default async function RootLayout({ children }: { children: React.ReactNode }) {

  return (
    <ThemeProvider>
        {children}
    </ThemeProvider>
  )
}
nmn commented 10 months ago

how can i make it work without the colors object, If possible

@alejandroyunes I don't understand what you're asking.

alejandroyunes commented 10 months ago

hi, yes I think what I was asking is that if primary isnt any of the createThemplate it takes what is in the colors object in defineVars,

//themes.ts
const DARK = '@media (prefers-color-scheme: dark)';

export const lightTheme = stylex.createTheme(colors, {
  bg: {
    default: "white",
    [DARK]: "white",
  }
})

export const darkTheme = stylex.createTheme(colors, {
  bg: {
    default: "black",
    [DARK]: "black",
  }
})
export const colors = stylex.defineVars({
  primary: 'red',
  bg: {
    default: "red",
    [DARK]: "blue",
  }
});

why do I need the bg in colors and in both themes?

nmn commented 10 months ago

I still don't understand what you're asking, but you should be able to write this instead:

//themes.ts
const DARK = '@media (prefers-color-scheme: dark)';

export const lightTheme = stylex.createTheme(colors, {
  bg: "white",
})

export const darkTheme = stylex.createTheme(colors, {
  bg: "black",
})
export const colors = stylex.defineVars({
  primary: 'red',
  bg: {
    default: "red",
    [DARK]: "blue",
  }
});

why do I need the bg in colors and in both themes?

You don't...

cly commented 7 months ago

Sorry to bring up an old thread but I'm not able to do dark mode without FOUCs.

Given this example...

// layout.tsx

import {darkTheme} from './color-themes';

export default function RootLayout({isDarkMode, children}) {
  return (
    <html {...stylex.props(isDarkMode && darkTheme)}>
      <head>{/* ... */}</head>
      <body>
        {children}
      </body>
    </html>
  );
}

I'm using Astro and during server-side rendering, dark mode is unknown so it defaults to light mode. Now on client hydration we get the light mode rendered, then once the React code kicks in and recognizes that dark mode is set, it re-renders.

The way I got this working in other styling libraries is by using descendent selectors similar to html[data-theme='dark'] .test. Even on the first render from SSR, the styles are correct. Is there an escape hatch here?

Thanks so much @nmn!

nmn commented 7 months ago

I'm using Astro and during server-side rendering, dark mode is unknown so it defaults to light mode.

This is the problem here. We have media queries for detecting dark mode. You shouldn't be trying to choose light mode or dark mode on the server at all.

I'm working on adding first-class support for light-dark(). Which will make doing this even more flexible.

But TLDR; Setting themes based on selectors such as html[data-theme='dark'] should be avoided as a best practice. But even if you're using them, they should only be used when the user has manually overridden their preferred color scheme which should be known on the server. If you don't know their preferred color scheme, you should be using Media Queries to choose automatically. (And use light-dark() in the future)

cly commented 7 months ago

Gotcha, so the rec is to store user prefs for dark / light on server and then use that for server side rendering.

FWIW, the stylex website uses html[data-theme=dark].

nmn commented 7 months ago

FWIW, the stylex website uses html[data-theme=dark].

The only SSR-safe ways of doing color theming is:

  1. To store the user pref
  2. Use media queries

Docusaurus generates a static bundle so there is no server to store user preferences on.

:(