├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── app ├── components │ ├── Box │ │ └── Box.ts │ ├── Text │ │ ├── Text.css.ts │ │ └── Text.tsx │ └── index.ts ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx └── routes │ └── index.tsx ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── styles ├── global.css.ts ├── index.ts └── sprinkles.css.ts ├── tsconfig.json └── tsup.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /app/styles 2 | 3 | node_modules 4 | 5 | /.cache 6 | /build 7 | /public/build 8 | .env 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Mark Dalgleish 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-vanilla-extract-prototype 2 | 3 | If you want to use [vanilla-extract](https://vanilla-extract.style) with [Remix,](https://remix.run) this is one way you could do it. 4 | 5 | The Remix team have said they're interested in providing official support for vanilla-extract, but this could work in the meantime with a few tradeoffs. 6 | 7 | ## Usage 8 | 9 | This project uses [pnpm.](https://pnpm.io) First, install dependencies. 10 | 11 | ```bash 12 | pnpm install 13 | ``` 14 | 15 | To run the dev server. 16 | 17 | ```bash 18 | pnpm dev 19 | ``` 20 | 21 | To check the production build. 22 | 23 | ```bash 24 | pnpm build 25 | pnpm start 26 | ``` 27 | 28 | ## Tradeoffs 29 | 30 | - A separate [tsup](https://github.com/egoist/tsup) process is needed to generate CSS + JS + types since we can't hook into the Remix compiler. 31 | - All styles from `.css.ts` files need to be manually re-exported from `/styles/index.ts`. This is then compiled into `/app/styles`. You can think of it as maintaining a style manifest file. This step could potentially be automated. 32 | - As a result of the previous point, your Remix code always needs to import styles from `~/styles`, even if the `.css.ts` file is in the same directory. If you don't do this, your vanilla-extract styles won't be compiled properly as they will go directly through the Remix compiler. 33 | - All styles are built into a single `index.css` file in `/app/styles` which your root route needs to include. 34 | - You can keep the file size down using [Sprinkles](https://vanilla-extract.style/documentation/packages/sprinkles) which generates compression-friendly atomic CSS. You can then access these classes at runtime via the type-safe `sprinkles` function. To reduce boilerplate in your React code, Sprinkles can be wired up to a `Box` component as demonstrated in this project, allowing you to access atomic styles via props. 35 | - This solution works within a single project, but doesn't scale well to a design system shared across multiple projects that require tree shaking of unused styles. You might be able to create a more complicated architecture that works around this, but this is why an official Remix integration would be preferred in the long run. 36 | 37 | ## Contributing 38 | 39 | This is a very rough prototype and hasn't been used in a production setting. If you've got any suggestions for how to improve this, please [let me know on Twitter.](https://twitter.com/markdalgleish) 40 | 41 | ## License 42 | 43 | MIT. 44 | -------------------------------------------------------------------------------- /app/components/Box/Box.ts: -------------------------------------------------------------------------------- 1 | import type { AllHTMLAttributes, ElementType } from "react"; 2 | import { createElement } from "react"; 3 | import { forwardRef } from "react"; 4 | import type { ClassValue } from "clsx"; 5 | import clsx from "clsx"; 6 | import type { Sprinkles } from "~/styles"; 7 | import { sprinkles } from "~/styles"; 8 | 9 | interface ExtendedBoxProps extends Sprinkles { 10 | as?: ElementType; 11 | className?: ClassValue; 12 | } 13 | 14 | type BoxProps = Omit, keyof ExtendedBoxProps> & 15 | ExtendedBoxProps; 16 | 17 | export const Box = forwardRef( 18 | ({ as = "div", className, ...props }, ref) => { 19 | const atomProps: Record = {}; 20 | const nativeProps: Record = {}; 21 | 22 | for (const key in props) { 23 | if (sprinkles.properties.has(key as keyof Sprinkles)) { 24 | atomProps[key] = props[key as keyof typeof props]; 25 | } else { 26 | nativeProps[key] = props[key as keyof typeof props]; 27 | } 28 | } 29 | 30 | const atomicClasses = sprinkles(atomProps); 31 | const customClasses = clsx(className); 32 | 33 | return createElement(as, { 34 | className: `${atomicClasses}${customClasses ? ` ${customClasses}` : ""}`, 35 | ...nativeProps, 36 | ref, 37 | }); 38 | } 39 | ); 40 | 41 | Box.displayName = "Box"; 42 | -------------------------------------------------------------------------------- /app/components/Text/Text.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | export const root = style({ 4 | fontFamily: "Comic Sans MS", 5 | fontSize: 24, 6 | lineHeight: "1.4", 7 | }); 8 | -------------------------------------------------------------------------------- /app/components/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { Box } from "../Box/Box"; 3 | import { Text as styles } from "~/styles"; 4 | 5 | export function Text({ children }: { children: ReactNode }) { 6 | return ( 7 | 14 | {children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Box } from "./Box/Box"; 2 | export { Text } from "./Text/Text"; 3 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrateRoot } from "react-dom/client"; 3 | 4 | hydrateRoot(document, ); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import type { EntryContext } from "@remix-run/node"; 3 | import { Response } from "@remix-run/node"; 4 | import { RemixServer } from "@remix-run/react"; 5 | import { renderToPipeableStream } from "react-dom/server"; 6 | 7 | const ABORT_DELAY = 5000; 8 | 9 | export default function handleRequest( 10 | request: Request, 11 | responseStatusCode: number, 12 | responseHeaders: Headers, 13 | remixContext: EntryContext 14 | ) { 15 | return new Promise((resolve, reject) => { 16 | let didError = false; 17 | 18 | const { pipe, abort } = renderToPipeableStream( 19 | , 20 | { 21 | onShellReady: () => { 22 | const body = new PassThrough(); 23 | 24 | responseHeaders.set("Content-Type", "text/html"); 25 | 26 | resolve( 27 | new Response(body, { 28 | headers: responseHeaders, 29 | status: didError ? 500 : responseStatusCode, 30 | }) 31 | ); 32 | 33 | pipe(body); 34 | }, 35 | onShellError: (err) => { 36 | reject(err); 37 | }, 38 | onError: (error) => { 39 | didError = true; 40 | 41 | console.error(error); 42 | }, 43 | } 44 | ); 45 | 46 | setTimeout(abort, ABORT_DELAY); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react"; 10 | 11 | export const meta: MetaFunction = () => ({ 12 | charset: "utf-8", 13 | title: "New Remix App", 14 | viewport: "width=device-width,initial-scale=1", 15 | }); 16 | 17 | export default function App() { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import stylesheet from "~/styles/index.css"; 2 | import { Text, Box } from "~/components"; 3 | 4 | export function links() { 5 | return [ 6 | { 7 | rel: "stylesheet", 8 | href: stylesheet, 9 | }, 10 | ]; 11 | } 12 | 13 | export default function Index() { 14 | return ( 15 | 22 | Hello from 🧁 vanilla-extract + 💿 Remix 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-vanilla-extract-prototype", 3 | "private": true, 4 | "sideEffects": [ 5 | "*.css.ts" 6 | ], 7 | "scripts": { 8 | "build": "pnpm run build:css && remix build", 9 | "build:css": "tsup", 10 | "dev": "concurrently \"pnpm run dev:css\" \"remix dev\"", 11 | "dev:css": "tsup --watch", 12 | "start": "remix-serve build" 13 | }, 14 | "dependencies": { 15 | "@remix-run/node": "^1.7.0", 16 | "@remix-run/react": "^1.7.0", 17 | "@remix-run/serve": "^1.7.0", 18 | "@vanilla-extract/css": "^1.7.3", 19 | "@vanilla-extract/sprinkles": "^1.4.1", 20 | "clsx": "^1.2.1", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0" 23 | }, 24 | "devDependencies": { 25 | "@remix-run/dev": "^1.7.0", 26 | "@remix-run/eslint-config": "^1.7.0", 27 | "@types/react": "^18.0.15", 28 | "@types/react-dom": "^18.0.6", 29 | "@vanilla-extract/esbuild-plugin": "^2.1.0", 30 | "concurrently": "^7.3.0", 31 | "eslint": "^8.20.0", 32 | "tsup": "^6.2.3", 33 | "typescript": "^4.7.4" 34 | }, 35 | "engines": { 36 | "node": ">=14" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markdalgleish/remix-vanilla-extract-prototype/ba7f3f48aaabd90bf67b370566244ecfec98655b/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // serverBuildPath: "build/index.js", 7 | // publicPath: "/build/", 8 | }; 9 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /styles/global.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle } from "@vanilla-extract/css"; 2 | 3 | globalStyle("body", { 4 | "@media": { 5 | "(prefers-color-scheme: dark)": { 6 | backgroundColor: "#111", 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /styles/index.ts: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | 3 | export { sprinkles } from "./sprinkles.css"; 4 | export type { Sprinkles } from "./sprinkles.css"; 5 | 6 | export * as Text from "~/components/Text/Text.css"; 7 | -------------------------------------------------------------------------------- /styles/sprinkles.css.ts: -------------------------------------------------------------------------------- 1 | import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"; 2 | 3 | const space = { 4 | none: 0, 5 | small: "4px", 6 | medium: "8px", 7 | large: "16px", 8 | // etc. 9 | }; 10 | 11 | const responsiveProperties = defineProperties({ 12 | conditions: { 13 | mobile: {}, 14 | tablet: { "@media": "screen and (min-width: 768px)" }, 15 | desktop: { "@media": "screen and (min-width: 1024px)" }, 16 | }, 17 | defaultCondition: "mobile", 18 | properties: { 19 | display: ["none", "flex", "block", "inline"], 20 | flexDirection: ["row", "column"], 21 | justifyContent: [ 22 | "stretch", 23 | "flex-start", 24 | "center", 25 | "flex-end", 26 | "space-around", 27 | "space-between", 28 | ], 29 | alignItems: ["stretch", "flex-start", "center", "flex-end"], 30 | paddingTop: space, 31 | paddingBottom: space, 32 | paddingLeft: space, 33 | paddingRight: space, 34 | // etc. 35 | }, 36 | shorthands: { 37 | padding: ["paddingTop", "paddingBottom", "paddingLeft", "paddingRight"], 38 | paddingX: ["paddingLeft", "paddingRight"], 39 | paddingY: ["paddingTop", "paddingBottom"], 40 | placeItems: ["justifyContent", "alignItems"], 41 | }, 42 | }); 43 | 44 | const colors = { 45 | lightBlue: "#bfdbfe", 46 | darkGray: "#1f2937", 47 | // etc. 48 | }; 49 | 50 | const colorProperties = defineProperties({ 51 | conditions: { 52 | lightMode: {}, 53 | darkMode: { "@media": "(prefers-color-scheme: dark)" }, 54 | }, 55 | defaultCondition: "lightMode", 56 | properties: { 57 | color: colors, 58 | background: colors, 59 | // etc. 60 | }, 61 | }); 62 | 63 | export const sprinkles = createSprinkles(responsiveProperties, colorProperties); 64 | 65 | // It's a good idea to export the Sprinkles type too 66 | export type Sprinkles = Parameters[0]; 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.js"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import { vanillaExtractPlugin } from "@vanilla-extract/esbuild-plugin"; 3 | 4 | export default defineConfig((options) => ({ 5 | entry: ["styles/index.ts"], 6 | outDir: "app/styles", 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | dts: true, 11 | format: "cjs", 12 | esbuildPlugins: [ 13 | vanillaExtractPlugin({ 14 | identifiers: options.watch ? "debug" : "short", 15 | }), 16 | ], 17 | })); 18 | --------------------------------------------------------------------------------