npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-foundry

v0.0.16

Published

A lightweight component development environment for React

Readme

react-foundry

A lightweight component development environment for React. Write a preview as a plain React component and it shows up on a canvas, in a navigation tree you define. No bundler config of your own.

  • Auto-discovery — finds your .preview.tsx files automatically
  • Your navigation — declare the tree in config; array order is display order
  • One primitive — a preview is just a React component, hooks and all
  • Typed nav paths — a misplaced preview is a compile error, with autocomplete
  • Theming — light/dark/system, driven by a few role-named color tokens
  • Accessibility — an axe-core checker that highlights the offending node on the canvas
  • Style isolation — foundry's own CSS stops at the canvas, so components render as they do in your app (one way)
  • Fast — built on Vite; the chrome ships precompiled, so nothing builds in your node_modules

Install

npm install --save-dev react-foundry

react and react-dom are peer dependencies you already have (React 18 or 19).

Quick start

Point your scripts at the foundry binary:

{
  "scripts": {
    "dev": "foundry dev",
    "build": "foundry build",
    "preview": "foundry preview"
  }
}

Add a foundry.config.ts at your project root:

import { defineConfig } from 'react-foundry'

export default defineConfig({
  previews: 'src/**/*.preview.tsx',
  title: 'My Components',
  nav: [{ label: 'Forms', children: [{ label: 'Button' }] }],
})

Write a preview next to a component:

import { createPreview, type NavPath } from 'react-foundry'
import { Button } from './button'

export const nav: NavPath = 'Forms/Button'

export const Primary = createPreview(() => <Button variant="primary">Go</Button>)

Run npm run dev and it appears on the canvas under Forms → Button → Primary.

Toolbar

The floating bar in the top-left corner holds every control over the environment itself. Each one has a keyboard shortcut, and hovering or focusing a button names both.

| Control | Key | What it does | | --- | --- | --- | | Component list | S | Shows or hides the shelf, the navigation tree down the left | | Controls panel | P | Shows or hides the props panel | | Theme | T | Flips between light and dark. On system, it flips away from whatever the system resolved to, since that is the theme you are actually looking at | | Accessibility check | A | Turns the axe-core panel on or off |

The shortcuts are bare letters, with no modifier. The obvious chords are already taken by browsers — CtrlShiftT reopens a tab, CtrlShiftP opens a private window — and this is a bar you reach for constantly.

The cost of bare letters is that they have to stay out of your component's way, so a keystroke is ignored when it lands on an input, a textarea, a select or a rich text editor on the canvas, when any of ctrl, cmd or alt is held, or when something closer to the keystroke has already handled it.

Shelf and panel state persists across reloads, which matters more than it sounds: editing a preview file reloads the page, and collapsing the whole layout on every save would be worse than the edit is worth.

CLI

Each command takes an optional [root] argument, defaulting to the current directory:

  • foundry dev [root] — start the dev server (also foundry serve, or just foundry)
  • foundry build [root] — build a static bundle for production
  • foundry preview [root] — serve the production build locally
  • foundry --version — the installed version, worth quoting in a bug report

Every command reads foundry.config.ts from that directory. If there is none, foundry says so and falls back to defaults rather than starting a working-looking server with an empty shelf. Worth watching for under a hoisting package manager, where the foundry binary is installed at your workspace root and will run happily from there.

Hot reloading

Editing a component, or the preview that renders it, patches the canvas in place. What reloads instead is anything that changes the shelf or the props panel, since neither is rebuilt by a hot patch:

| You edit | What happens | | --- | --- | | A component, or a stylesheet it imports | Patched in place | | A render body, in a preview file with no controls | Patched in place | | Anything, in a preview file that declares controls | Page reloads | | A preview's nav, its exports, or a label | Page reloads, shelf updates | | A preview file added or deleted | Page reloads | | nav or title in your config | Page reloads | | theme in your config | Stylesheet swaps, no reload | | foundry.providers.tsx | Page reloads |

A file that declares controls reloads on every edit, not just on schema edits. The props panel renders from the schema captured when the preview loaded, and a hot patch replaces the render without replacing that object, so patching a schema change would leave the panel describing the previous shape. Foundry can't tell the two edits apart, because a schema can reach the preview by name (controls: buttonControls) and then reads identically either side of an edit to it.

A patch keeps the state held inside render, in both forms. React's refresh transform only registers a function passed straight to createPreview, so foundry registers an options-form render itself, under the export's name; React then treats the edited function as an update of the one on screen rather than as a new component. Changing the hooks inside it still remounts it, which is React's own rule.

Keeping a refresh boundary

React Fast Refresh patches a module in place only while every one of its exports is component-like, or the value of a non-component export is unchanged by the edit. So a file exporting a component alongside a helper:

export const Badge = (props: BadgeProps) => { ... }
export const badgeVariants = cva({ ... })   // not a component

keeps its boundary while you are editing Badge, and loses it the moment badgeVariants is what changed: the update invalidates upward and the page reloads instead. Moving the helper into its own file avoids it. This is a React rule rather than a foundry one, and it applies to your components as much as to previews. The browser console names the offending export when it happens.

Configuration

Create a foundry.config.ts in your project root:

import { defineConfig } from 'react-foundry'

export default defineConfig({
  previews: 'src/components/**/*.preview.tsx',
  port: 5173,
  title: 'My Components',
  nav: [
    { label: 'Foundations', children: [{ label: 'Colors' }, { label: 'Typography' }] },
    { label: 'Forms', children: [{ label: 'Button' }, { label: 'Input' }] },
  ],
  theme: {
    colors: {
      light: { accent: '#0ea5e9' },
      dark: { accent: 'oklch(70% 0.15 240)' },
    },
  },
})

| Option | Default | Notes | | --- | --- | --- | | previews | 'src/components/**/*.preview.tsx' | Glob for preview files. Requires restart. | | nav | [] | The navigation tree. Hot-reloadable; the shelf and the generated NavPath union both update on save. | | port | 5173 | Dev server port. Requires restart. | | host | 'localhost' | Dev server host. Requires restart. | | title | none | Display title for the instance. Hot-reloadable. | | theme | none | Theme customization. Hot-reloadable. | | navTypes | true | Whether to emit foundry-nav.gen.d.ts. Set false when you derive the union with NavPathsOf instead. Hot-reloadable; turning it off mid-session removes the file. | | navTypesPath | inferred | Exact path for the generated NavPath types. Defaults next to your previews; override for layouts the inference can't reach. Hot-reloadable. | | viteConfig | none | Vite config overrides. Requires restart. |

Changing previews, port or host logs a warning naming the key and both values, so a restart-required edit doesn't quietly leave the server running on the old one. A changed previews glob is the one worth watching for: without the warning it looks like HMR has broken, when the file you're editing is simply outside the pattern being watched.

The config is bundled with esbuild into node_modules/.cache/react-foundry/ and imported from there, so import.meta.dirname inside your config points at that cache directory, not your project. Use process.cwd() for anything that needs a project path:

viteConfig: { plugins: [somePlugin({ root: process.cwd() })] }

Config files are resolved in this order, first match wins:

foundry.config.mjs
foundry.config.js
foundry.config.ts
.foundry/config.mjs
.foundry/config.js
.foundry/config.ts

Navigation

nav is optional, and the two modes behave differently enough to choose deliberately:

| | Shelf order | NavPath | | --- | --- | --- | | nav declared | Exactly as written, nested as deeply as you like | The union of every path in the tree | | nav omitted | Inferred from the nav values your previews declare, sorted alphabetically | Stays string |

If your shelf is alphabetical and ignoring the order you wanted, that is the second row: you have no nav in your config.

Either way a preview whose path is not in the config still appears, appended at the end with a warning, so nothing is ever silently dropped.

With a tree declared, every path in it including parents becomes part of a NavPath union that preview files check against, so a mistyped path is a compile error with autocomplete rather than a preview quietly landing in the wrong place. There are two ways to get that union.

Generated types (the default)

Foundry writes src/foundry-nav.gen.d.ts (or the project root if you have no src/) on every server start and whenever the config changes, and NavPath picks it up ambiently. Add that file to your .gitignore. It carries its own eslint-disable and prettier-ignore header, so your lint and format config does not need to know about it.

Nothing is written when nav is empty: with no tree to describe, the file would say exactly what NavPath already falls back to.

Deriving it from the config

NavPathsOf reads the same union straight off your config, with no generated file at all:

// foundry.config.ts
import { defineConfig, defineNav, type NavPathsOf } from 'react-foundry'

const nav = defineNav([{ label: 'Forms', children: [{ label: 'Button' }] }])

const config = defineConfig({ nav, navTypes: false })
export default config

export type AppNavPath = NavPathsOf<typeof config> // 'Forms' | 'Forms/Button'
// button.preview.tsx
import type { AppNavPath } from '../foundry.config'

export const nav: AppNavPath = 'Forms/Button'

defineNav is what keeps the labels literal enough to flatten (as const works too, but checks nothing where you write it). navTypes: false turns the codegen off, which is the part that actually retires the artifact: no gitignore entry, no lint exemptions, and no question of whether types were emitted before tsc ran. Turning it off also deletes a file an earlier run wrote, so a stale union cannot outlive the setting.

The tradeoff is that AppNavPath is a project-local type your previews import by name, rather than an ambient NavPath. That is arguably clearer, since its origin is visible at the import.

If there is no tree to read, NavPathsOf resolves to string rather than to an empty union, matching what NavPath does without a config. So adding the type before you add the nav tree typechecks; it just does not constrain anything yet.

Monorepo layouts

This section is about placing the generated file. If you derive the union with NavPathsOf instead, none of it applies: an imported type resolves wherever the import does, so there is nothing to place.

Declaration merging only narrows NavPath inside a TypeScript project that compiles both the generated file and your .preview.tsx files. When your config lives in one package but the previews live in another (say the config is in apps/foundry and previews in packages/react/src), Foundry writes foundry-nav.gen.d.ts next to the previews by inferring the directory from the previews glob base, so the previews' own tsconfig picks up the augmentation with no extra setup.

Because that augmentation binds during the previews' package compilation, react-foundry must be a dependency of that package too, not only the app that holds foundry.config.ts. Add it as a devDependency where the previews live, or createPreview and NavPath resolve to Cannot find module 'react-foundry' (TS2307) across every preview file.

If that inference can't reach the right place, set navTypesPath to the exact output file, resolved against the config root:

export default defineConfig({
  previews: '../../packages/react/src/**/*.preview.tsx',
  navTypesPath: '../../packages/react/src/foundry-nav.gen.d.ts',
})

Either way, add the generated file to that package's .gitignore. Symlinked workspace previews are served with no extra config: foundry detects your workspace root and adds it to Vite's file-serving allow-list automatically. Only reach for viteConfig.server.fs.allow if previews live outside that detected root.

Theming

Foundry's shell derives its palette from a few role-named tokens, set independently for light and dark. Values accept any CSS color (hex, rgb(), hsl(), oklch(), named colors) or a bare OKLCH triplet.

Anchors — set these to shift the whole ramp at once:

| Token | Role | | --- | --- | | bg | base surface / paper pole | | fg | strong text / ink pole | | accent | focus rings, links, active states |

Surfaces and text — derived from the anchors by default; override any one for precision:

| Token | Role | | --- | --- | | canvas | the backdrop behind your preview | | panel | shelf, props panel, and dock backgrounds | | border | inputs, dividers, panel edges | | textMuted / textBody / textStrong | secondary / body / emphasis text |

theme: {
  colors: {
    light: { canvas: '#faf9f7', accent: '#0ea5e9' },
    dark: { canvas: '#0b0b0c', accent: '#38bdf8' },
  },
  fonts: { sans: 'Inter, sans-serif' },
}

Overriding an anchor (bg/fg/accent) recomputes every derived token in the browser, so the whole shell shifts from one or two values. Overriding a specific token (like canvas) pins just that one. The surfaces and text mix in OKLCH internally, but you never have to: pass plain hex.

Fonts (sans, mono) are mode-agnostic: one value for both themes. Foundry bundles Instrument Sans; if you point sans at another family, make sure it is actually available (a system font, your own @font-face, or a font-host link), since foundry can't bundle it for you.

Creating component previews

A preview is a React component that fills the canvas. Create a .preview.tsx file alongside your component, place it in the tree with a nav export, and wrap each preview in createPreview:

import { createPreview, type NavPath } from 'react-foundry'
import { useState } from 'react'
import { Button } from './button'

export const nav: NavPath = 'Forms/Button'

export const Primary = createPreview(() => <Button variant="primary">Go</Button>)
export const Danger = createPreview(() => <Button variant="danger">Careful</Button>)

// Hooks work. There is no separate concept for a preview that holds state.
export const Counter = createPreview(() => {
  const [count, setCount] = useState(0)
  return <Button onClick={() => setCount(count + 1)}>Clicked {count}</Button>
})

// Use the options form when the export name cannot express the label.
export const AllSizes = createPreview({
  label: 'Every Size',
  render: () => (
    <>
      {sizes.map((size) => (
        <Button key={size} size={size} />
      ))}
    </>
  ),
})

// Exported but not wrapped, so this is not a nav entry.
export const sizes = ['small', 'medium', 'large'] as const

That renders as:

Forms
  Button
    Primary
    Danger
    Counter
    Every Size

The rules:

  • nav places the file. It is typed against your config, so it autocompletes. Omit it and the filename is used instead.
  • Only exports wrapped in createPreview become previews. Helpers, fixtures, and constants can live in the same file without leaking into the shelf.
  • Labels come from export names, de-camelCased: AllSizes becomes All Sizes. Pass label to override.
  • Order is what you wrote. Previews appear in source order, sections in config order. Nothing is sorted behind your back.
  • URLs use the export name, never the label. AllSizes lives at /Forms/Button/AllSizes whatever you label it, so rewording a label never breaks a link.
  • render is a component. Foundry mounts it rather than calling it, so hooks work inside it in both forms, controls or not, and a controlled component's value can live right there with no wrapper component extracted to hold it. React keys that state on render's identity, which is stable when it is written as a literal in a module-level createPreview call, as above. Do not build previews in a factory that runs during render and recreates render each time: every pass would be a new component to React, remounted with its state gone.

How discovery reads your files

Foundry finds previews by parsing the source, not by importing it. That is what keeps each preview in its own lazy chunk instead of the initial bundle: the whole shelf is built without evaluating a single preview module.

The cost is a small authoring convention. A preview must be written as:

export const Primary = createPreview(/* ... */)

with createPreview referenced by that name. These forms parse as not a preview, and the export simply will not appear in the shelf:

import { createPreview as preview } from 'react-foundry' // aliased import
export function Primary() {}                             // not a createPreview call
export { Primary }                                       // re-export, not a declaration

An explicit label must likewise be a string literal at the top level of the options object. A computed one (a template literal, a variable, a constant) falls back to the de-camelCased export name rather than failing:

export const AllSizes = createPreview({
  label: 'Every Size',        // used
  label: `Every ${thing}`,    // ignored, falls back to "All Sizes"
  render: () => null,
})

A file that calls createPreview but yields no previews under these rules logs a warning naming the file, so a silent miss is visible in the dev server output.

Two files cannot share both a nav path and an export name. A duplicate (nav, exportName) pair means one of the two previews disappears, with a console warning; the survivor is decided by lexicographic file path. This is a realistic hazard when migrating from a tool where two files carried the same title.

Controls

Give a preview editable controls with defineControls, and render receives their live values:

import { createPreview, defineControls } from 'react-foundry'
import { Button } from './button'

export const Playground = createPreview({
  controls: defineControls({
    variant: { type: 'select', options: ['primary', 'danger'], default: 'primary' },
    disabled: { type: 'boolean', default: false },
    label: { type: 'text', default: 'Click me' },
  }),
  render: (values) => (
    <Button variant={values.variant} disabled={values.disabled}>
      {values.label}
    </Button>
  ),
})

Values are typed from the schema, so values.variant narrows to 'primary' | 'danger' and a typo is a compile error. Control types: text, boolean, number, range, select, radio, color, and list for an array of any of them (see Lists).

The panel names each control after its key, humanized (onSurface reads "On Surface"). Give a control a label to name it yourself; the key still names the prop, and the panel keeps the two linked by showing the key beside the prop's definition.

Every control carries an info mark. Hover or focus it for the prop the control drives, as declared on the component: variant?: 'primary' | 'danger', with the prop's JSDoc under it. The dev server reads these with the TypeScript compiler from the controlsFor call a preview's controls came from, following a hoisted or imported schema and a spread of one into another. Where there is nothing to read, a schema from defineControls or a project without typescript, the mark shows the control's own definition instead: its kind, options or range, and default. The docs for a preview are read when it is first opened, which can take a moment while the compiler loads the project's types, and read again when you edit a source file, so a change to a component's props shows up without a reload.

render is a component, so a preview of a controlled component keeps its value in a hook right there, and a control edit re-renders it with the new props rather than remounting it:

export const Playground = createPreview({
  controls: controlsFor(Select, {
    width: { type: 'radio', options: ['auto', 'full'], default: 'auto' },
  }),
  render: (v) => {
    const [value, setValue] = useState('a')
    return <Select options={OPTIONS} value={value} onValueChange={setValue} width={v.width} />
  },
})

Note that a file declaring controls reloads the page on every edit rather than patching in place, so the panel can never describe a schema the canvas has moved on from. See Hot reloading.

Typing controls against a component

defineControls has no relationship to any component, so nothing stops a schema drifting from the thing it claims to exercise. controlsFor takes the component too:

const cardControls = controlsFor(Card, {
  title: { type: 'text', default: 'Alert rule' },
  padding: { type: 'radio', options: ['small', 'medium', 'large'], default: 'medium' },
  elevated: { type: 'boolean', default: false },
})

A control naming no prop, a control type the prop cannot take, and an option outside the prop's own union all stop compiling. A prop no input can express takes no plain control at all, only a derive (below), so a text box on a render prop is a compile error rather than a panel offering a value no call site can produce.

Either way, a controls schema is a props declaration. Written inline or through defineControls, it declares props for the preview's own render function, which is itself a component and free to render whatever it likes. controlsFor points that declaration at a component you already have, so a preview cannot claim to exercise one whose props it does not describe.

Worth knowing:

  • Write the schema inline, as above. Building it separately and passing it by name widens type: 'select' to string before controlsFor sees it, and the error you get names the widening rather than the cause.
  • A ReactNode prop takes a text control, since a string is a valid ReactNode. So children: { type: 'text' } works. A props panel cannot author JSX; a derive can, see below.
  • A prop typed string | number gets no plain control, and one typed 2 | 3 | 4 gets a plain number input rather than a dropdown, because options currently holds strings.
  • Props inherited from a DOM element come along, so a component extending ComponentProps<'button'> offers every aria-* attribute in autocomplete.

Deriving a prop's value from a control

Some props have types no input can express: a component (icon: LucideIcon), a node built from a flag, an array you would rather size with a slider than edit row by row. Any scalar control can carry a derive that maps the control's own value to the prop's type:

const selectControls = controlsFor(Select, {
  options: { type: 'range', min: 1, max: 10, default: 4, derive: (n) => CLIENTS.slice(0, n) },
  width: { type: 'radio', options: ['auto', 'sm', 'md', 'lg', 'full'], default: 'auto' },
})

const statCardControls = controlsFor(StatCard, {
  icon: { type: 'select', options: ['globe', 'gauge'], default: 'globe', derive: (name) => ICONS[name] },
})

const pageLeadControls = controlsFor(PageLead, {
  titleAccessory: { type: 'boolean', default: false, derive: (on) => (on ? <Badge content="Beta" /> : undefined) },
})

The panel draws the input exactly as it would without the derive, and the URL holds the input's value. What changes is what render receives: v.options is SelectOption[], not a number. The key is still checked against the component, so optoins is still a compile error; only the input type is freed, and only through the mapping. The return type is checked against the prop, so a derive that returns a string where the prop wants a component is a compile error too. The parameter is typed from the control: a range hands it a number, a boolean a boolean, and a select the union of its options, so ICONS[name] above needs no cast. A derive is accepted on a group member as well.

derive receives its own control's value and nothing else. It cannot read another control, which keeps it a per-prop mapping rather than a place to compose the preview. That is the line between the two helpers: derive produces one prop's value from one input, and defineControls is for controls that drive a composition the preview assembles itself in render. A derive on a select or radio in defineControls or an inline schema receives string; the narrowing to the option union is controlsFor's.

Lists

An array prop takes a list: rows drawn from one of schema, which is a scalar control for an array of strings or a group of them for an array of objects.

const selectControls = controlsFor(Select, {
  options: {
    type: 'list',
    of: { value: { type: 'text' }, label: { type: 'text', default: 'Untitled' } },
    default: [{ value: 'acme', label: 'Acme' }],
  },
})

const tagControls = controlsFor(TagList, {
  tags: { type: 'list', of: { type: 'select', options: ['new', 'beta'] }, default: ['new'] },
})

The panel draws a section per row with the row's fields and a remove button, and an add button that appends a row of the of defaults. render receives the array, typed from of: v.options is { value: string; label: string }[]. The rows travel in the URL whole, as JSON, and the list is left out of the URL while it equals its default.

controlsFor checks the row schema against the item type the same way it checks a group against an object prop: a key the item does not have, a control the key cannot take, and a typo in a default row are all compile errors. A row's controls may carry a derive, and a select in a row narrows to its options as one at the top level does.

of is a control or a group, never another list, and a list cannot sit inside a group. That is the one level of nesting below the list the schema allows, for the same reasons a group holds no group: a deeper tree is hard to draw legibly in a panel and costs more type instantiation than it earns.

Accessibility

Foundry runs axe-core against the canvas, scoped to your preview and nothing else: none of foundry's own chrome is in the scan.

Once enabled, the panel below the canvas scans on its own: on navigation, on a control change, and on a theme flip, each after a short debounce so the preview has settled before axe reads it back. Collapsing the panel suspends scanning... re-run check forces a scan whenever you want one.

Each violation lists the nodes that tripped it. The crosshair on a node outlines it in the canvas and scrolls it to center; click again to clear, or hover the button to preview the outline without committing to it.

Two things the panel reports rather than hides:

  • Could not be checked is axe's third bucket: rules it ran but could not reach a verdict on, like text over a background image. They are counted separately so a clean Passed never claims more than was actually checked.
  • Contrast is measured from rendered pixels, which bounds it twice. It describes the theme that was active at scan time, so the mode is labeled in the header, and it covers only the part of an overflowing canvas that was on screen. Scroll the rest into view and re-run.

Providers

Your components often rely on app-wide React context: a design-system theme provider, a data client, i18n, a router. Give foundry a foundry.providers.tsx at your project root that exports a Provider, and it wraps every preview in it, so components render the same way they do in your real app.

import type { FoundryProvider } from 'react-foundry'
import { ThemeProvider } from '@my/design-system'
import { QueryClientProvider } from '@my/data'

export const Provider: FoundryProvider = ({ children, theme }) => (
  <ThemeProvider mode={theme}>
    <QueryClientProvider>{children}</QueryClientProvider>
  </ThemeProvider>
)
  • theme is foundry's resolved mode ('light' | 'dark'), so a design-system provider can track foundry's own light/dark toggle. Ignore it if you don't need it.
  • The provider wraps inside the preview canvas, so app context reaches your components while foundry's styling stays on foundry's side of that boundary. See Style isolation for exactly how far that guarantee reaches.
  • It is mounted on every screen, including the home screen and a group landing where no preview is selected. So a design system that does document-level work on mount (a theme class on <html>, dir, font loading, a portal root) has done it before you pick anything.
  • The file is optional. Without it, previews render unchanged.

The file may be foundry.providers.{tsx,jsx,ts,js}. Editing it reloads the page, since every preview mounts inside it. Two things to know for a real project:

  • The first time you add the file, foundry pulls its new dependencies into the graph, so Vite may re-optimize and reload once more than usual. That is expected, not a bug.
  • If your provider imports workspace packages symlinked outside your project root, foundry serves them automatically by allow-listing your detected workspace root. Only if they live outside that root do you need to extend viteConfig.server.fs.allow.

Extending Vite

Foundry runs its own Vite pipeline. To extend it (plugins, aliases, etc.), pass a partial Vite config via viteConfig:

export default defineConfig({
  viteConfig: {
    plugins: [/* your plugins */],
    resolve: { alias: { '@': '/src' } },
  },
})

If your components author their own styles with vanilla-extract, add its plugin here:

import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'

export default defineConfig({
  viteConfig: { plugins: [vanillaExtractPlugin()] },
})

Provide a single React plugin instance only through viteConfig if you need to configure it; foundry already includes @vitejs/plugin-react, so don't add a second copy.

Foundry pins Vite 8, which resolves tsconfig path aliases natively. If you reach for vite-tsconfig-paths out of habit, Vite prints a deprecation notice on every start. Use the built-in instead:

export default defineConfig({
  viteConfig: { resolve: { tsconfigPaths: true } },
})

If you do keep a plugin that needs your project directory, remember the config runs from a cache directory: pass process.cwd(), not import.meta.dirname.

Style isolation

The guarantee runs one way: nothing foundry sets, resets, or inherits reaches your component on the canvas. Foundry's resets are excluded from the canvas subtree, and its typography is anchored on each piece of chrome rather than on body or html, which the canvas would otherwise inherit from.

The reverse is not true. Your stylesheets are loaded into foundry's document, so they reach foundry's own chrome: a CSS reset or preflight, element selectors, inherited typography, and anything painted on body all apply to the shelf, toolbar and props panel as well as to your components. That is usually harmless and occasionally not, so it is worth knowing which side of the line you are on.

Two elements are foundry's:

  • #root is foundry's mount point, and foundry paints its own layout root over it. A background you set there is covered rather than fighting the canvas for the viewport.
  • body is painted with foundry's canvas color.

License

MIT