ricokahler / hacker-ui

A design system for the modern developer. (development on hiatus)
https://hacker-ui.com/
MIT License
241 stars 10 forks source link

CSS-in-JS implementation #15

Closed ricokahler closed 4 years ago

ricokahler commented 4 years ago

In case you missed it, Hacker UI ships with its own CSS-in-JS solution.

I feel very good about the direction of this CSS-in-JS solution however it needs more work until its stable and optimized. I started working on it in this repo until I realized it would be more organized to split it off into a separate project.

You can check the progress of that library here. It's called react-style-system.

This issue will be up until this library uses react-style-system.

ricokahler commented 4 years ago

Alright so I've been experimenting and failing desperately (with lots of hacky code) so I'm now posting here as a lil call for help lol 😅

Anyway, I'm currently working on optimizing the CSS-in-JS solution which is a relatively involved task. Here is what I've done/what I'm trying to do:

To clarify, I'd like to ship both the standalone implementation and the SSR implementation. I think both will be useful depending the environment you're running code in (e.g. the standalone version is good for codesandbox, the SSR version is good for JAMstack apps).


Why write another CSS-in-JS lib anyway?

I answered this question over on the readme of react-style-system. The gist is: I really like Material UI's style system. It's really close to the features I'm looking for but there are a few things that could take it to the next level (in particular, near zero-runtime CSS support similar to linaria).

This issue is priority for me because I want a solid styling system foundation before we write more CSS.


A review of the styling system API

First you import the function createStyles which returns a hook

import React from 'react';
import { createStyles } from 'hacker-ui';

const useStyles = createStyles(({ css, theme }) => ({
  root: css`
    color: background-color: ${theme.colors.brand},
  `,
  title: css`
    font-weight: bold;
  `,
}));

This hook then intercepts your props. This allows it to compose over the incoming style-related props like className and style.

function MyComponent(props) {
  // `Root` and `styles` are created by `useStyles`.
  // `title` comes from this component's API
  const { Root, styles, title, ...restOfProps } = useStyles(props);

  return <Root>
    <h1 className={styles.title}>{title}</h1>
  </Root>
}

This is important because it enables the useStyles hook to add style related props to the component's API including the styles API. The styles API is exactly the same as the classes API from Material UI (if you're familiar).

TL;DR, this useStyles way of styling components is very nice because it's component-centric. I can go into details on why if you're curious.

The current implementation and its problems

Currently, the alpha version styling solution works 100% in the browser. It creates CSS classes during the component's mount via a useLayoutEffect.

https://github.com/ricokahler/hacker-ui/blob/648c1fed782098be4b017e40840b756c6c723bc8/src/createStyles.tsx#L112-L130

This is obviously less than ideal because the layout effect slows down the initial render. You can feel this when switching between pages in the docs that have lots of elements on them.

Optimizing the styling solution

There are two paths I think we should take to optimizing the styling solution:

  1. Optimizing the standalone version (i.e. the browser version that creates CSS classes during runtime). The standalone version is obviously not the ideal solution in the long term but as a work item, we should still audit this version and try to make it faster
  2. Creating an SSR/JAMstack version. This version would extract out the CSS-in-JS into static style sheets that have placeholder values for properties via CSS variables.

Details on the SSR/JAMstack version

I have a some-what working proof of concept in my react-style-system repo. The gist of how it works is:

  1. Transform components that import createStyles so that the useStyles hook can be invoked and injected with some mock values. Using some logic, we can determine whether a template literal expression should in a class definition should be converted into a CSS variable placeholder or executed with a static value. The end result is some static css that looks like this:
.Anchor--28fdf510-root {
  color: var(--Anchor--28fdf510-root-0);
  -webkit-text-decoration: underline;
  text-decoration: underline;
  -webkit-transition: color 250ms;
  transition: color 250ms;
}
.Anchor--28fdf510-root:active {
  color: var(--Anchor--28fdf510-root-1);
}
  1. Transform the components a second time so that we convert the createStyles calls with CSS into the createStyles calls that populate the CSS variables.
// before transform
import { readableColor } from 'polished';
const useStyles = createStyles(({ css, theme }) => ({
  // ignore the contents of the styles, they don't make sense
  root: css`
    background-color: ${theme.colors.brand};
    color: ${readableColor(theme.colors.brand)};
  `,
  title: css`
    color: ${theme.colors.brand};
  `,
}));
// after transform
import { readableColor } from 'polished';
const useStyles = createStyles(({ theme }) => ({
  root: [theme.colors.brand, readableColor(theme.colors.brand)],
  title: [theme.colors.brand],
  classNamePrefix: 'Example--32fa343e',
}));

The implementation of createStyles will also be swapped out with an SSR version that takes the values from above and creates a style object that simply assigns CSS variables via the React style prop. Below is a simplified version of the code to communicate what I mean.

// vastly simplified ssr createStyles
function createStylesSsr(styleFn) {
  function useStyles(incomingProps, Component = 'div') {
    const theme = useTheme();

    const cssVariableValues = useMemo(() => {
      const { classNamePrefix, ...classes } = styleFn({theme});
      const flattenedEntries = Object.entries(classes).map(([classKey, cssVariableArray]) => {
        return cssVariableArray.map((cssValue, i) => [`${classNamePrefix}-${classKey}-${i}`, cssValue]);
      }).flat();

      return Object.fromEntries(flattenedEntries);
    }, [theme, styleFn]);

    function Root(rootProps) {
      return <Component
        style={{
          ...rootProps.style,
          ...incomingProps.style,
          ...cssVariableValues,
        }}
       />
    }

    return { Root, /* ... */ };
  }

  return useStyles;
}

the result of cssVariableValues will look something like this:

{
  'Example--32fa343e-root-0': '#00f',
  'Example--32fa343e-root-1': '#000',
  'Example--32fa343e-title-0': '#000',
}

and the way the hook is setup to return the Root component, this root component can easily apply these style variables internally while still exposing a normal component API externally.


Help wanted 👋

Long story short, I'd really like some help getting all of this working. I have a proof of concept (with some really hacky code) in the v0.1.0 branch in react-style-system but I'm struggling getting this to work smoothly with babel.

If you're interested in this project. Let me know and we can set up some communication channels!

ricokahler commented 4 years ago

A lot of progress on this has been made in #27 . Take a look if you're interested!

ricokahler commented 4 years ago

I've eventually figured most of this out 🤷‍♀️