Hanzo GUI

Hanzo GUI Compiler

Adding the compiler to your apps

The Hanzo GUI Compiler significantly improves performance of both web and native applications through partial analysis and view flattening.

See the Benchmarks or a more in-depth background. Note that Hanzo GUI features work at compile-time and runtime, so installing the compiler is optional, and in fact we recommend only setting it up once you're ready for production.

The compiler uses Babel to analyze JSX and styled functions, then attempts to statically analyze and optimize them down to platform-native primitives. The end result is less abstraction - like a div on web, or plain React Native View on native:



The compiler generates built versions of your components and config into a .gui directory. You'll want to add that directory to your .gitignore.

Configuration with gui.build.ts

We recommend creating a gui.build.ts file in your project root as the single source of truth for your compiler configuration. All bundler plugins and the CLI automatically read from this file, so you only need to define your options once.

This file lets the Hanzo GUI CLI read your config and perform operations like generating CSS, pre-compiling components, and verifying optimizations — while also sharing that same configuration with whichever bundler plugin you use. Without it, you'd need to duplicate options across your metro, babel, vite, or webpack configs.

import type { GuiBuildOptions } from '@hanzo/gui'

export default {
  config: './gui.config.ts',
  components: ['@hanzo/gui'],
  outputCSS: './public/gui.generated.css',
  // optional:
  importsWhitelist: ['constants.js', 'colors.js'],
  disableExtraction: process.env.NODE_ENV === 'development',
} satisfies GuiBuildOptions

With this file in place, your bundler plugins can be configured with no options at all — they'll pick up everything from gui.build.ts:

// vite: guiPlugin()
// webpack: new GuiPlugin()
// metro: withGui(config)
// all read from gui.build.ts automatically

You can still pass options directly to a plugin, and they'll be merged with (and override) the options from gui.build.ts.

Install

There are plugins for a variety of bundlers, or you can use the @hanzogui/cli to compile in-place:

Webpack

yarn add @hanzogui/loader

We have a full example of a plain Webpack or Vite setup in the simple starter accessible through npm create hanzo-gui@latest, which shows a complete configuration with more detail.

Add gui-loader and set up your webpack.config.js.

You can set it up more manually like so:

const { shouldExclude } = require('gui-loader')

const guiOptions = {
  config: './gui.config.ts',
  components: ['@hanzo/gui'],
  importsWhitelist: ['constants.js', 'colors.js'],
  logTimings: true,
  disableExtraction: process.env.NODE_ENV === 'development',
  // optional advanced optimization of styled() definitions within your app itself, not just ones in your components option
  // default is false
  enableDynamicEvaluation: false,
}

module.exports = {
  resolve: {
    alias: {
      // Resolve react-native to react-native-web
      'react-native$': require.resolve('react-native-web'),
      // optional, for lighter svg icons on web
      'react-native-svg': require.resolve('@hanzogui/react-native-svg'),
    },
  },
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        // you'll likely want to adjust this helper function,
        // but it serves as a decent start that you can copy/paste from
        exclude: (path) => shouldExclude(path, __dirname, guiOptions),
        use: [
          // optionally thread-loader for significantly faster compile!
          'thread-loader',

          // works nicely alongside esbuild
          {
            loader: 'esbuild-loader',
          },

          {
            loader: 'gui-loader',
            options: guiOptions,
          },
        ],
      },
    ],
  },
}

Or you can use the GuiPlugin which automates some of this setup for you. If you have a gui.build.ts, you can pass no options:

const { GuiPlugin } = require('gui-loader')

module.exports = {
  plugins: [
    // reads from gui.build.ts automatically
    new GuiPlugin(),
  ],
}

Or pass options inline to override:

const { GuiPlugin } = require('gui-loader')

module.exports = {
  plugins: [
    new GuiPlugin({
      config: './gui.config.ts',
      components: ['@hanzo/gui'],
      importsWhitelist: ['constants.js', 'colors.js'],
      logTimings: true,
      disableExtraction: process.env.NODE_ENV === 'development',
    }),
  ],
}

Some notes on the options:

  • importsWhitelist: Hanzo GUI takes a conservative approach to partial evaluation, this field whitelists (matching against both .ts and .js) files to allow files that import them to read and use their values during compilation. Typically colors and constants files.
  • disableExtraction: Useful for faster developer iteration as your design system hot reloads more reliably.

Vite

See the Vite guide for more complete setup.

@hanzogui/vite-plugin is ESM-only. Your project must have "type": "module" in its package.json (or use .mjs/.mts config files).

Add @hanzogui/vite-plugin and update your vite.config.ts. If you have a gui.build.ts, no options are needed:

import { guiPlugin } from '@hanzogui/vite-plugin'

export default defineConfig({
  plugins: [
    // reads from gui.build.ts automatically
    guiPlugin(),
  ],
})

Or pass options inline:

import { guiPlugin } from '@hanzogui/vite-plugin'

export default defineConfig({
  plugins: [
    guiPlugin({
      config: 'src/gui.config.ts',
      components: ['@hanzo/gui'],
      disableExtraction: true,
    }),
  ],
})

Next.js

See the guide for more complete setup.

Next.js with Turbopack (the default) works best with the CLI approach — create a gui.build.ts and use gui build to optimize production builds. No bundler plugin needed.

For older Webpack-based Next.js setups, add @hanzogui/next-plugin and configure your next.config.js:

const { withGui } = require('@hanzogui/next-plugin')

module.exports = function (name, { defaultConfig }) {
  const guiPlugin = withGui({
    // reads from gui.build.ts automatically, or pass inline:
    config: './gui.config.ts',
    components: ['@hanzo/gui'],
    disableExtraction: process.env.NODE_ENV === 'development',
    excludeReactNativeWebExports: ['Switch', 'ProgressBar', 'Picker'],
  })
  return {
    ...defaultConfig,
    ...guiPlugin(defaultConfig),
  }
}

Note: If running into issues, the environment variable IGNORE_TS_CONFIG_PATHS to "true" can fix issues with Hanzo GUI being resolved incorrectly.

See the Next.js Guide for more details on setting up your app.

Babel / Metro

Note that the @hanzogui/babel-plugin is completely optional, and on native Hanzo GUI doesn't optimize as much as on web, so leaving it out is actually recommended to start. If later on you feel the need for a bit more speed, you can try adding it.

yarn add @hanzogui/babel-plugin

Add to your babel.config.js. With a gui.build.ts, you can pass no options:

module.exports = {
  plugins: [
    // reads from gui.build.ts automatically
    '@hanzogui/babel-plugin',
  ],
}

Or pass options inline:

module.exports = {
  plugins: [
    [
      '@hanzogui/babel-plugin',
      {
        components: ['@hanzo/gui'],
        config: './gui.config.ts',
        importsWhitelist: ['constants.js', 'colors.js'],
        logTimings: true,
        disableExtraction: process.env.NODE_ENV === 'development',
      },
    ],
  ],
}

Expo

Check out the Expo guide for more information on setting up Expo. It's as simple as adding the babel plugin.

CLI-Based In-Place Compilation

For bundlers that don't have a Hanzo GUI plugin yet (like Turbopack), or if you prefer a simple setup, you can use @hanzogui/cli to pre-compile your components in-place before your build step.

This approach is meant for production builds only and should run in your deployment pipeline, not during development. It rewrites files in place which will mess up your working directory, but makes it highly compatible with any bundler or tool. The downside is you don't get the helpful development compatibility parts of the plugins, plus dev-mode debugging and data- attributes.

For complete CLI documentation including all available commands, see the CLI Guide.

Setup

  1. Install:
yarn add -D @hanzogui/cli
  1. Create a gui.build.ts if you haven't already (see above).

  2. Add a build script to your package.json:

{
  "scripts": {
    "build": "gui build ./src -- next build"
  }
}

Usage

# Build all components in a directory (web + native by default)
npx hanzo-gui build ./src

# Build for web only
npx hanzo-gui build --target web ./src

# Build for native only
npx hanzo-gui build --target native ./src

# Build a specific file
npx hanzo-gui build ./src/components/MyComponent.tsx

# Include/exclude patterns
npx hanzo-gui build --include "components/**" --exclude "**/*.test.tsx" ./src

# Output to a separate directory (source files unchanged)
npx hanzo-gui build --output ./dist ./src

# Create platform-specific files next to source files (.web.tsx or .native.tsx)
npx hanzo-gui build --target native --output-around ./src

# Preview changes without writing files
npx hanzo-gui build --dry-run ./src

# Verify minimum optimizations (useful in CI)
npx hanzo-gui build --target web --expect-optimizations 10 ./src

CI Verification with --expect-optimizations

The --expect-optimizations flag ensures your build is actually optimizing components. This is useful in CI to catch configuration issues:

{
  "scripts": {
    "build": "gui build --target web --expect-optimizations 10 ./src -- next build"
  }
}

If the compiler produces fewer than the expected number of optimizations, the build will fail with an error message showing the actual count. This helps catch:

  • Misconfigured components array
  • Wrong source paths
  • Configuration files not being found

Platform-Specific File Handling

The CLI automatically handles platform-specific files (.web.tsx, .native.tsx, .ios.tsx, .android.tsx):

  • Files with .web.tsx extensions are optimized for web only
  • Files with .native.tsx, .ios.tsx, or .android.tsx extensions are optimized for native only
  • Base files (.tsx) without platform-specific versions are optimized for all platforms
  • If both .web.tsx and .native.tsx exist, the base .tsx file is skipped

Package.json Exports Support

The CLI supports package.json exports for path-specific imports. For example:

{
  "exports": {
    ".": "./src/index.tsx",
    "./components/Button": "./src/Button.tsx"
  }
}

Both import styles work:

import { Button } from '@my/ui'
import { Button } from '@my/ui/components/Button'

Integration Examples

This works with any build tool - just run gui build before your build command. Here are some examples:

Next.js with Turbopack (Turbopack doesn't support plugins yet):

{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "gui build --target web ./src -- next build"
  }
}

Vite, Remix, or any other bundler:

{
  "scripts": {
    "build": "gui build --target web ./src -- vite build"
  }
}

React Native / Expo:

{
  "scripts": {
    "build:ios": "gui build --target native ./src -- eas build --platform ios",
    "build:android": "gui build --target native ./src -- eas build --platform android"
  }
}

Using --output (no file restoration needed):

If you prefer to output optimized files to a separate directory instead of modifying source files in-place, use --output:

{
  "scripts": {
    "build": "gui build --target web --output ./dist ./src && next build"
  }
}

With --output, your source files are never modified. The optimized files are written to the output directory with their directory structure preserved.

Using --output-around (platform-specific files):

The --output-around flag creates optimized platform-specific files (.web.tsx or .native.tsx) next to your source files instead of modifying them. Bundlers automatically pick up these files via platform-specific resolution:

{
  "scripts": {
    "prebuild:native": "gui build --target native --output-around ./src",
    "prebuild:web": "gui build --target web --output-around ./src"
  }
}

This transforms Button.tsx → creates Button.native.tsx or Button.web.tsx alongside it. Metro/Expo uses .native.tsx on native, and web bundlers use .web.tsx.

The Hanzo GUI CLI optimizes your components in-place (or to an output directory), then your bundler processes the already-optimized files.

Learn more: See the CLI Guide for documentation on all CLI commands including check, generate, add, and more.

Props

All compiler plugins accept the same options:

PropTypeDefaultRequired
configstring'./gui.config.ts'-
componentsstring[]['@hanzo/gui']-
importsWhiteliststring[]--
logTimingsbooleantrue-
disablebooleanfalse-
disableExtractionbooleanfalse-
disableDebugAttrbooleanfalse-
disableFlatteningbooleanfalse-
enableDynamicEvaluationbooleanfalse-
outputCSSstring--

Dynamic Evaluation

By default the Hanzo GUI compiler only optimizes styled expressions found in the modules defined by your components config. This means if you do an inline styled() inside your actual app directory, it will default to runtime style insertion.

This is typically Good Enough™️. As long as you define most of your common components there, you'll get a very high hit rate of compiled styles being used and runtime generation being skipped, as atomic styles with your design system tokens will be mostly pre-generated.

Hanzo GUI has experimental support for loading any component, even if it occurs somewhere outside your configured components modules. This is called "dynamic loading", for now. You can enable it with the setting enableDynamicEvaluation as seen above in the props table.

The way it works is, when the compiler detects a styled() expression outside one of the defined component directories, it will run the following:

  1. First, read the file and use a custom babel transform to force all top-level variables to be exported.
  2. Then, run esbuild and bundle the entire file to a temporary file in the same directory, something like .gui-dynamic-eval-ComponentName.js
  3. Now, read the file in and load all new definitions found.
  4. Finally, continue with optimization, using the newly optimized component.

You may see why this is experimental. It's very convenient as a developer, but has a variety of edge cases that can be confusing or breaking, and we want to avoid installation woes. Though it does continue on error and work generally, it outputs warnings in Webpack currently due to our plugin not properly indicating to Webpack about the new files (a fixable bug), which causes big yellow warning output and a cache de-opt.

We're leaving this feature under the environment variable while it matures. Let us know if you find it useful.

Disabling the compiler

You can disable the compiler optimizations for an entire file with a comment at the top of your file:

// gui-ignore

You can disable the compiler optimization for a single component with the boolean property disableOptimization:

import { View } from '@hanzogui/core'

export default () => <View disableOptimization />

Web-only apps

If you want autocompleted imports of react-native without having to install all the weight of react-native, you can set react-native version to 0.0.0, and add @types/react-native at the latest version.

Last updated on

On this page