solid-styled

v1.0.0-next.2for Solid 2.0

solid-styled

Near zero-runtime, reactive stylesheets for SolidJS

npm i solid-styled

Write CSS. Ship scoped CSS.

Each template compiles to a static sheet scoped to its component, and each ${…} becomes a CSS variable. The output below came from solid-styled/compiler when this page was built.

Button.tsx
import type { JSX } from '@solidjs/web';
import { createSignal } from 'solid-js';
import { css } from 'solid-styled';

export default function Button(): JSX.Element {
  const [color, setColor] = createSignal('orchid');

  css`
    button {
      color: ${color()};
      border: 2px solid currentColor;
      padding: 0.5rem 1rem;
    }
  `;

  return (
    <button
      onClick={() => {
        setColor('hotpink');
      }}
    >
      {color()}
    </button>
  );
}
solid-styled/compiler
import { useSolidStyled as _useSolidStyled, createCSSVars as _createCSSVars, mergeStyles as _mergeStyles } from "solid-styled";
import type { JSX } from '@solidjs/web';
import { createSignal } from 'solid-js';
import { css } from 'solid-styled';

const sheet_1 = "c-c4307938-0";
const css_1 = "button[s\\:c-c4307938-0]{color:var(--s-v-c4307938-1);border:2px solid;padding:.5rem 1rem}";
export default function Button(): JSX.Element {
  const vars_1 = _createCSSVars();
  const [color, setColor] = createSignal('orchid');

  (_useSolidStyled(sheet_1, 1, css_1), vars_1(() => ({ "--s-v-c4307938-1": color() })));

  return (
    <button s:c-c4307938-0 style={vars_1()}
      onClick={() => {
        setColor('hotpink');
      }}
    >
      {color()}
    </button>
  );
}

Both spellings, one sheet

button[s\:c-c4307938-0]{color:var(--s-v-c4307938-1);border:2px solid;padding:.5rem 1rem}

Values change. The sheet stays.

Interpolated values are written to the component's own elements as custom properties. Move a slider: the style attribute updates, the sheet in <head> is never rewritten.

style="…"
style writes
0
sheet rewrites
0
Ribbons.tsx before and after the compiler
src/islands/Ribbons.tsx
import type { JSX } from '@solidjs/web';
import { css } from 'solid-styled';

interface RibbonsProps {
  hue: string;
  tilt: string;
  gap: string;
}

export default function Ribbons(props: RibbonsProps): JSX.Element {
  css`
    div {
      --hue: ${props.hue};
      position: relative;
      aspect-ratio: 1;
      rotate: ${props.tilt}deg;
    }
    span {
      position: absolute;
      inset: 0;
      transition: translate 0.3s ease;
    }
    .top {
      clip-path: polygon(8% 6%, 80% 6%, 94.7% 30%, 22.7% 30%);
      background: linear-gradient(
        90deg,
        hsl(var(--hue) 55% 37%),
        hsl(calc(var(--hue) + 14) 50% 52%)
      );
      translate: 0 calc(${props.gap}px * -1);
    }
    .spine {
      clip-path: polygon(8% 6%, 38% 6%, 92% 94%, 62% 94%);
      background: linear-gradient(
        160deg,
        hsl(calc(var(--hue) + 6) 48% 51%),
        hsl(calc(var(--hue) + 28) 50% 52%)
      );
    }
    .bottom {
      clip-path: polygon(92% 94%, 20% 94%, 5.3% 70%, 77.3% 70%);
      background: linear-gradient(
        270deg,
        hsl(calc(var(--hue) + 28) 50% 52%),
        hsl(calc(var(--hue) + 40) 100% 80%)
      );
      translate: 0 ${props.gap}px;
    }
  `;

  return (
    <div role="img" aria-label="The solid-styled mark, drawn with three CSS variables">
      <span class="top" />
      <span class="spine" />
      <span class="bottom" />
    </div>
  );
}
solid-styled/compiler
import { useSolidStyled as _useSolidStyled, createCSSVars as _createCSSVars, mergeStyles as _mergeStyles } from "solid-styled";
import type { JSX } from '@solidjs/web';
import { css } from 'solid-styled';

interface RibbonsProps {
  hue: string;
  tilt: string;
  gap: string;
}

const sheet_1 = "c-dae047bb-0";
const css_1 = "div[s\\:c-dae047bb-0]{--hue:var(--s-v-dae047bb-1);aspect-ratio:1;rotate:calc(var(--s-v-dae047bb-2)*1deg);position:relative}span[s\\:c-dae047bb-0]{transition:translate .3s;position:absolute;inset:0}.top[s\\:c-dae047bb-0]{clip-path:polygon(8% 6%,80% 6%,94.7% 30%,22.7% 30%);background:linear-gradient(90deg,hsl(var(--hue) 55% 37%),hsl(calc(var(--hue) + 14) 50% 52%));translate:0 calc(calc(var(--s-v-dae047bb-3)*1px)*-1)}.spine[s\\:c-dae047bb-0]{clip-path:polygon(8% 6%,38% 6%,92% 94%,62% 94%);background:linear-gradient(160deg,hsl(calc(var(--hue) + 6) 48% 51%),hsl(calc(var(--hue) + 28) 50% 52%))}.bottom[s\\:c-dae047bb-0]{clip-path:polygon(92% 94%,20% 94%,5.3% 70%,77.3% 70%);background:linear-gradient(270deg,hsl(calc(var(--hue) + 28) 50% 52%),hsl(calc(var(--hue) + 40) 100% 80%));translate:0 calc(var(--s-v-dae047bb-4)*1px)}";
export default function Ribbons(props: RibbonsProps): JSX.Element {
  const vars_1 = _createCSSVars();
  (_useSolidStyled(sheet_1, 1, css_1), vars_1(() => ({ "--s-v-dae047bb-1": props.hue, "--s-v-dae047bb-2": props.tilt, "--s-v-dae047bb-3": props.gap, "--s-v-dae047bb-4": props.gap })));

  return (
    <div s:c-dae047bb-0 style={vars_1()} role="img" aria-label="The solid-styled mark, drawn with three CSS variables">
      <span s:c-dae047bb-0 style={vars_1()} class="top" />
      <span s:c-dae047bb-0 style={vars_1()} class="spine" />
      <span s:c-dae047bb-0 style={vars_1()} class="bottom" />
    </div>
  );
}

Styles stay where you wrote them.

Each element a component writes gets its scope attribute, and every selector in its sheet requires it. These demos are live solid-styled components.

Scoped by default

Elements the component writes, children included, carry its scope. Elements another component renders do not.

Written here

Passed as children

Rendered by Foreign

import type { JSX } from '@solidjs/web';
import { css } from 'solid-styled';

function Frame(props: { children: JSX.Element }): JSX.Element {
  return <div>{props.children}</div>;
}

function Foreign(): JSX.Element {
  return <p>Rendered by Foreign</p>;
}

export default function Boundary(): JSX.Element {
  css`
    p {
      color: var(--accent);
      font-weight: 700;
    }
  `;

  return (
    <>
      <p>Written here</p>
      <Frame>
        <p>Passed as children</p>
      </Frame>
      <Foreign />
    </>
  );
}

:global(…)

Opts one selector out of scoping, here to reach spans rendered by a child component.

scopedreactivestatic
import type { JSX } from '@solidjs/web';
import { css } from 'solid-styled';

function Labels(): JSX.Element {
  return (
    <>
      <span>scoped</span>
      <span>reactive</span>
      <span>static</span>
    </>
  );
}

export default function Tags(): JSX.Element {
  css`
    div {
      display: flex;
      gap: 0.5rem;
    }
    div > :global(span) {
      padding: 0.25rem 0.75rem;
      border-radius: 999px;
      background: var(--gradient-strong);
      color: white;
    }
  `;

  return (
    <div>
      <Labels />
    </div>
  );
}

use:solid-styled

Forwards the scope to a component, such as Dynamic, that spreads its props on an element.

import type { JSX } from '@solidjs/web';
import { dynamic } from '@solidjs/web';
import { css } from 'solid-styled';

const Button = dynamic(() => 'button');

export default function Forward(): JSX.Element {
  css`
    button {
      padding: 0.5rem 1rem;
      border: 0;
      border-radius: 999px;
      background: var(--gradient-strong);
      color: white;
    }
  `;

  return (
    <>
      <Button type="button" use:solid-styled>
        Forwarded
      </Button>
      <Button type="button">Not forwarded</Button>
    </>
  );
}

@global

Hoists a block out of scoping. This site's fonts, tokens and base styles are one @global block in GlobalStyles.tsx.

4.3kB after LightningCSS

Compiled ahead. Rendered on the server.

The compiler parses with oxc and lowers and minifies with LightningCSS. What reaches the browser is a string and a few function calls.

Server rendered, no flash

StyleRegistry collects every sheet used during a render. This page was prerendered that way and carries 17 sheets in its <head>. On the client, sheets already there are matched by their s:id and not inserted again.

docs/ssr.md
import { renderToString } from '@solidjs/web';
import { StyleRegistry, renderSheets } from 'solid-styled';

const styles = [];

const html = renderToString(() => (
  <StyleRegistry styles={styles}>
    <App />
  </StyleRegistry>
));

const head = renderSheets(styles);

Near zero runtime

2.2kB gzipped

The whole runtime: insert a sheet once, count references, remove it with the last user. Everything else happens at build time.

Any bundler

unplugin-solid-styled runs ahead of the Solid JSX transform.

import solidStyled from 'unplugin-solid-styled';

solidStyled.vite(options);
solidStyled.rollup(options);
solidStyled.webpack(options);
solidStyled.esbuild(options);