Yugen UI

Performance

The settings that keep an app built with Yugen UI small and fast, with numbers measured on the library's builds. Sizes are minified and gzipped.

Import paths and bundlers

Importing from yugen-ui or from yugen-ui/<Folder> gives the same production build with most bundlers. esbuild code splitting and Node without a bundler are the exceptions.

Where the code runsyugen-uiyugen-ui/<Folder>
Vite, Rollup, webpack, Next.js (production)Only what the app usesSame as the root
esbuild with splitting: trueThe parts that load on demand land in the first chunkOnly what the app uses
Node, no bundler (Vitest, Jest, SSR, dev server){ Button }: 498 files, 1.6 s cold14 files, 61 ms

esbuild assigns whole files to chunks before it drops unused exports. From the root, import { Calendar } then brings the Toaster, Breadcrumb's menu and the citation card into the first chunk: 76.46 kB, against 31.60 kB from yugen-ui/Calendar. Two subpaths hold such a part themselves: yugen-ui/AIKit also exports Citation, so StreamingMarkdown from it has the citation card in the first chunk, and toast from yugen-ui/Toast brings the Toaster along, because both live in one file.

Next.js: add the package to optimizePackageImports so the dev server and the server bundle load only what a page uses. The config, and which folder each name comes from, are in Quick start › Import paths.

What loads on demand

Four parts that only some apps render have their own chunk. Each one starts loading as soon as it is rendered on the client.

PartStarts loadingFirst chunk, 0.15 → 0.16Loaded later
Toaster of withToasterFirst client render (after hydration with SSR){ YugenProvider } 24.91 → 13.31 kB12.5 kB
Breadcrumb's “…” menuWhen a BreadcrumbOverflow mounts{ Breadcrumb } 40.17 → 13.47 kB28.37 kB
Citation card in StreamingMarkdownWhen the first citation chip mounts{ StreamingMarkdown } 49.28 → 27.34 kB24.25 kB
TagPicker's Sortable (reorderable)When a TagPicker with reorderable mounts{ TagPicker } 47.35 → 38.86 kB7.10 kB
  • Measured on a Vite 8 build with React external. An app that never renders the part never loads it.
  • Until the code arrives, Breadcrumb renders the same “…” button and StreamingMarkdown the same chip, with the same size and ARIA, so nothing moves. A click or key press in that time opens the menu or the card once the code arrives, and focus moves to the real button. If loading fails, the next interaction tries again. TagPicker shows the same tags; dragging and Alt + arrow keys work once Sortable has loaded, and a tag that has focus keeps it.
  • The server HTML has these placeholders and no Toaster, so hydration matches (SSR & Next.js).
  • toast() calls made before the Toaster has loaded show once it mounts. A <Toaster /> you render as a child of the root provider is part of the first chunk instead: the first toast does not wait for a chunk, and its live region is in the page from the first paint.
  • Two components got smaller without loading anything later. Nav no longer imports Tooltip (AppShell's Sidebar adds the rail tooltip): { Nav, NavItem } 29.94 → 11.97 kB. Calendar no longer bundles the Vietnamese locale: { Calendar } 32.39 → 30.42 kB.

CSS size

theme.css makes Tailwind scan every component in the package. The CLI's css command writes a file with only the components your code imports.

Starter templateCSS (gzip)
yugen-ui/theme.css26.7 kB
yugen-ui.css written by yugen css (5 component folders)13.2 kB
theme-core.css alone, no component6.74 kB

On the other templates yugen css gives 11.8 to 20.0 kB, against about 26 kB (Styling › Only the CSS you use). Run it in predev / prebuild; yugen add refreshes it, and yugen css --check exits with 1 when it is out of date (CLI).

Without Tailwind, yugen css --precompile compiles the same list with your project's Tailwind packages and replaces yugen-ui/styles.css: 26.75 kB down to 12.79 to 21.06 kB on the templates.

Keyframes live in @theme, so Tailwind writes only the ones your classes use: none for theme-core.css alone, one for an app that uses only Button.

Theme before the first paint

Server HTML does not know which theme the user picked. Two helpers put it on <html> before the browser paints.

yugenHtmlAttributes() returns the attributes applyToDocument writes, for the <html> of your root layout. yugenThemeScript() returns a <head> script that applies the saved theme, or the OS setting. Both are plain functions from yugen-ui or yugen-ui/YugenProvider/document and run in a Server Component. Setup on SSR & Next.js.

On the client, YugenProvider writes the attributes in a layout effect, so a child's useEffect already sees them. Without SSR (a Vite SPA) the browser paints index.html before React runs: use the inline script from Quick start.

Re-renders

YugenProvider has two contexts. A theme change re-renders only the components that read the theme.

ContextHoldsRe-renders when it changes
Themetheme, brand, dirPortalled surfaces (Dialog, Menu, Popover…), charts, CodeBlock, callers of useYugenTheme() and useYugen()
Settingslocale, density, messages, defaultsEvery component that reads its defaults or strings
1,800 controls, jsdom0.150.16
Theme switch, children stable66–117 ms1.3–1.9 ms
Parent re-render with the same messages inline65–67 ms0.1–0.2 ms

The first row needs the app's elements to stay the same. When the component that holds the theme state also creates the app's elements, every component re-renders on a switch (about 68 ms in the same test). Keep the theme state in a component that receives the app as children: a theme switch then re-renders that component only, and React skips the tree apart from the theme readers.

// ThemeRoot.tsx: the theme state lives here, the app comes in as children
import * as React from "react";
import { YugenProvider, type YugenTheme } from "yugen-ui";

export const SetThemeContext = React.createContext<(theme: YugenTheme) => void>(() => {});

export function ThemeRoot({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = React.useState<YugenTheme>("light");
  return (
    <SetThemeContext.Provider value={setTheme}>
      <YugenProvider applyToDocument theme={theme}>
        {children}
      </YugenProvider>
    </SetThemeContext.Provider>
  );
}

// main.tsx
createRoot(document.getElementById("root")!).render(
  <ThemeRoot>
    <App />
  </ThemeRoot>,
);
  • messages and defaults written inline are compared by value, two levels deep (component, then key), so they need no useMemo. A function or element created during render is a new value each time: define it outside the component.
  • In your own components, useYugenTheme() returns { theme, brand, dir } and useYugenLocale() the locale; useYugen() re-renders on either change (Theming).
  • Components with variants (Button, Input, Badge, Card…) merge their classes once per combination of props: a server render of 1,000 Buttons takes 6.35 ms instead of 12.58 ms. A className is merged on top on every render (8.13 ms).

Charts with many points

Moving the pointer over LineChart, AreaChart or BarChart no longer recomputes the chart, and a Sparkline with a fixed width measures nothing.

Hover step, jsdom0.150.16
LineChart, 3 series of 500 points9.65 ms1.05 ms
LineChart, 3 series of 2,000 points39.2 ms1.11 ms
BarChart, 150 groups of 3 bars10.6 ms2.33 ms
  • Scales, paths, labels and the static SVG layers are memoized on the props, the width and the hidden series. Keep data and series stable (state, a constant or useMemo) so a parent re-render does not rebuild them.
  • Fill and stroke sit on the series group instead of each mark: a LineChart with 4 series of 200 points and markers renders 183.9 kB of markup instead of 263.3 kB.
  • Give Sparkline a number width in tables and lists: it then uses no ResizeObserver. 200 Sparklines with width={120} commit once instead of three times and mount in 60–153 ms instead of 229–514 ms. width="auto" follows the parent.
  • In your own components, useElementSize({ enabled: false }) skips measuring when the size is known (Hooks). Choosing a chart form and its palette: Data visualization.