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

@sanity/pandacss-tsdown-plugin

v0.3.1

Published

tsdown plugin for Panda CSS that extracts the generated CSS into a lightningcss-optimized file, complementary to @pandacss/vite

Readme

@sanity/pandacss-tsdown-plugin

A tsdown plugin for publishing Panda CSS libraries to npm with a separate stylesheet.

It is for library builds: add it to the tsdown.config.ts of a package that styles with Panda, and it generates Panda's files, rewrites the style calls it can resolve statically into class names, and extracts the CSS into one Lightning CSS-optimized file next to the JavaScript. Applications keep using the official @pandacss/vite plugin in their Vite config; the two load the same panda.config.ts. A package built with this plugin with cascadeLayers: false that also runs its sources through Vite (a dev server, Storybook, Vitest) can add the removeCascadeLayers() plugin next to @pandacss/vite there, so that CSS cascades like the published build.

Installation

pnpm add --save-dev @sanity/pandacss-tsdown-plugin tsdown
  • tsdown is a peer dependency. The Panda compiler and transformer the plugin runs are its own dependencies, so there is nothing else to install for the build.
  • @pandacss/dev is recommended but not required (an optional peer): it provides defineConfig for panda.config.ts, the "prepare": "panda codegen" script below, and the panda CLI for debugging, such as validating the config.
  • Node.js 22.18 or later, like tsdown and Panda.

Quick start

A package with Panda sources under src/:

{
  "name": "my-library",
  "type": "module",
  "files": ["dist"],
  "sideEffects": ["*.css"],
  "imports": {
    "#styled-system/*": "./styled-system/*/index.js"
  },
  "scripts": {
    "prepare": "panda codegen",
    "build": "tsdown"
  }
}
  • imports maps the generated styled-system directory, so sources import #styled-system/css.
  • prepare generates that directory on install. The plugin regenerates it when it builds, but the editor, the type checker and the test runner need it before any build runs.
  • sideEffects keeps the stylesheet import that inject adds from being tree-shaken by consumers' bundlers.
// panda.config.ts
import {defineConfig, type Config} from '@pandacss/dev'

export default defineConfig({
  include: ['./src/**/*.ts', './src/**/*.tsx'],
  importMap: '#styled-system',
  hash: true,
  preflight: false,
  presets: [],
  jsxStyleProps: 'none',
  shorthands: false,
  forceImportExtension: true,
  strictPropertyValues: true,
  // …
} satisfies Config)

satisfies Config makes the type checker reject options that don't exist, which defineConfig alone lets through.

// tsdown.config.ts
import {pandacssPlugin} from '@sanity/pandacss-tsdown-plugin'
import {defineConfig} from 'tsdown'

export default defineConfig({
  entry: ['src/index.ts'],
  exports: true,
  plugins: [pandacssPlugin({inject: true, exports: {nodeCompat: true}})],
})

This build writes dist/index.js with the style calls rewritten, dist/bundle.css with the extracted CSS, and — because of the two options — an import "my-library/bundle.css" at the top of the entry chunk plus the conditional ./bundle.css export in package.json (see Loading the stylesheet). tsdown's exports: true is what lets the plugin write that export; without it, the export has to be maintained by hand.

Used standalone, the plugin only extracts the CSS: inject and exports are off by default, and so is minify.

Using it through @sanity/tsdown-config

@sanity/tsdown-config wraps the plugin in its panda option, with Sanity's defaults on top: inject: true, exports: {nodeCompat: true}, and CSS syntax transformed for the @sanity/browserslist-config browsers when the build's target names none. Minification stays opt-in.

// tsdown.config.ts
import {defineConfig} from '@sanity/tsdown-config'

export default defineConfig({
  tsconfig: 'tsconfig.dist.json',
  panda: true,
})

Pass an object instead of true to customize, with the same options as the plugin.

What the build does

Sources

The Panda config's include and exclude globs decide which modules are Panda sources. Every file they cover is scanned when a build starts and contributes to the stylesheet — also files no entry imports, like panda cssgen. Modules outside the globs are left alone, even when they call css().

Codegen

Before anything is scanned or transformed, the plugin writes Panda's generated files (the same output as panda codegen) to the config's outdir, so the generated types, runtime and class name hashes match the config the stylesheet is extracted with. Under tsdown this happens once for all formats, after tsdown's clean step and before any build starts: clean may list the generated directory, and the separate CJS declaration build finds the generated types. Only files whose content changed are written; a current directory is left untouched. Set codegen: false to leave codegen to your own scripts, and outdir to write the files elsewhere.

Transform

By default (transform: true, unlike @pandacss/vite, where it is off), each Panda source is rewritten in place: css({color: 'brand'}) becomes "color_brand", so the module no longer imports #styled-system/css. Set transform: false to keep every call and only extract the CSS.

Only calls that can be resolved statically are rewritten:

  • A conditional composition like css(base, active && extra) is resolved into a static expression, or into a call of a small class-merging helper from @pandacss/transformer that is bundled into the output — not Panda's full runtime.
  • A call with a dynamic value, like css({color}), cannot be resolved. It stays as it is, Panda's css() runtime is bundled into the output for it (from styled-system, through the imports map), and the build warns about it. The runtime call produces a class name, but the stylesheet only has rules for the values Panda could see statically — arbitrary dynamic values have none.

Stylesheet

The CSS is extracted into bundle.css (the fileName option) once per output format:

  • Every Panda cascade layer is requested, and the output keeps the @layer blocks of the layers that have rules — but not the @layer reset, base, tokens, recipes, utilities; order declaration. Declaring the order is the consuming application's job (Panda apps do this in their own CSS). Set cascadeLayers: false to publish the CSS without layers instead (see Cascade layers).
  • Lightning CSS always processes the result: it transforms the syntax for the configured browsers (see Browser targets), normalizes it, and removes Panda's --made-with-panda marker together with the rules and layers that leaves empty.
  • Minification is separate and off by default; set minify: true.
  • With cascadeLayers: false, the layers are removed last, from the CSS Lightning CSS printed (minified or not), so Lightning CSS and any lightningcss.visitor still see Panda's layered stylesheet.

Cascade layers

Panda puts its rules in cascade layers, and the stylesheet keeps them by default: cascadeLayers defaults to true, here and in @sanity/tsdown-config's panda option. @sanity/tsdown-config plans to switch its default to false in a later release of its own, once @sanity/ui and sanity have adopted the option.

For a published library, the layers have a cost: CSS outside any layer beats every layered rule, whatever its specificity. A consumer who overrides a base style with an unlayered rule — even button { font-weight: inherit } in a reset such as Tailwind's preflight — also overrides the library's :hover, :focus-visible and selected states, so focus rings, hover colors and bold weights disappear.

cascadeLayers: false removes every @layer block and statement. The library's rules then cascade like any unlayered CSS, by specificity and then source order, the way a vanilla-extract or plain CSS stylesheet does:

  • A consumer rule overrides a base style when it is at least as specific and loads after the library's stylesheet — button {…} beats a global button style, and a one-class override such as styled(Button) beats a recipe's .button base.
  • The library's state rules (.button:is(:hover, [data-hover])) are more specific than a one-class override, so they keep winning.
  • Every selector is left as Panda wrote it, so no rule gets more specific than it was.

The rules are reordered so that source order gives them the precedence the layers gave them: reset, base, tokens, recipes and utilities in that order, and within a layer, its sublayers before its own rules. Panda depends on the latter: it emits the compositions sublayer (text and layer styles) after the utilities that must beat it, and recipes as base, variants and compound_variants sublayers, with slot recipes in a separate recipes.slots block. A @media, @supports or @container block that holds layers is split along them, each part keeping its condition, so the order holds across it too.

This is the last step of the build, on the CSS Lightning CSS printed: the stylesheet is parsed with PostCSS, the rules are moved out of their @layer blocks and the result is printed again, so every rule keeps the text Lightning CSS gave it — the same syntax transforms, merging and minification as with the layers on, and the same formatting at the rules' new indentation. Nothing is matched textually: a @layer inside a custom property's value or a string is data, and the layer names (escapes, comments and all) are read by Lightning CSS's parser.

Source order can't reproduce everything layers do, though:

  • A more specific rule in an earlier layer, which used to lose to a later layer, now wins. In Panda's output, that is a recipe's base state style: _hover in a recipe's base (.button:is(:hover, [data-hover])) now beats its variants (.button--tone_brand) and utilities (.color_ink) while hovered. Declare states in the variants to keep them in step.
  • !important declarations no longer reverse the layer order: the later of two !important declarations wins, like normal ones.
  • Load order matters for consumers: an override has to load after the library's stylesheet, or be more specific.

Keep the layers when the consumers are Panda apps, or other apps that declare a layer order of their own: they can place the library's layers among theirs (@layer reset, base, tokens, recipes, utilities, app;) and rank the library's rules below or above their own as a whole, which source order can't give them. Keeping the layers never shields the library's rules from unlayered CSS, though — a layered rule yields to every normal unlayered declaration, whatever its specificity, so a consumer's button {…} overrides the library's states as well as its base styles. Turn the layers off for a library meant to be overridden with plain CSS, styled-components or a reset such as Tailwind's preflight: the base styles then yield to an override at least as specific, and the more specific state rules keep winning. The option works with minify, target, inject, exports and watch mode, and with lightningcss.visitor, whose visitors receive the layered stylesheet, since the layers are removed after Lightning CSS has run. It takes precedence over the Panda config's polyfill.

Panda's own polyfill option is no substitute: it emulates the layer order by appending :not(#\#) chains to every selector, one more ID for each layer. That keeps the library's own precedence, but even its global button style becomes as specific as an ID selector (and its utilities as ten), so no consumer rule short of !important can override it. The two options can't be combined.

The library's own Vite setups, such as its Storybook or Vitest browser suite, which build its sources with @pandacss/vite instead of loading bundle.css, get the same unlayered CSS from the Vite plugin.

Browser targets

Which syntax Lightning CSS transforms depends on the targets:

  • target takes esbuild-style targets like 'chrome90' or ['chrome90', 'safari16.2'], and tsdown's 'baseline-widely-available'. It defaults to tsdown's own target, and a browser named more than once resolves to its lowest version.
  • A target that doesn't specify browsers, such as node20, means no syntax transform — the plugin matches @tsdown/css here. @sanity/tsdown-config falls back to @sanity/browserslist-config in that case, and also accepts browserslist queries like 'defaults'.
  • lightningcss.targets wins over target; target: false disables the syntax transform.

Loading the stylesheet

Consumers have to load bundle.css. The plugin can help in two ways, both off by default:

  • inject: true adds an import of the stylesheet to every JavaScript entry chunk whose module graph reaches a module with Panda styles (css(), cva(), sva(), pattern, recipe and token() calls, or Panda JSX) — import "./bundle.css" in ESM output, require("./bundle.css") in CJS output, inserted after "use strict" and other directives. The CSS is not inlined into the JavaScript and no <style> element is created; the consumer's bundler handles the import.
  • exports: true publishes the stylesheet as the ./bundle.css export of the package, and inject then uses the self-referential import "my-library/bundle.css". With exports: {nodeCompat: true} the export is conditional: the browser and style conditions select the stylesheet, while node and default select a no-op bundle-css.js shim (with a bundle-css.d.ts for types), so the import resolves in runtimes that cannot load .css files. The export is written to package.json through tsdown's exports feature when that is enabled.

Loading through exports relies on the consumer's resolver honoring those conditions; bundlers generally do, plain <script type="module"> in a browser does not.

Watch mode

In tsdown --watch, the plugin watches every scanned source (imported or not), the directories the include globs are rooted in (so added files are picked up), and the Panda config with its imports. A rebuild reloads the config: a change to it regenerates the files, re-extracts the stylesheet and transforms every module again — class renames (prefix, hash, separator) and include changes reach the output on the next rebuild. The generated directory and the build's output directory are excluded from watching, so the build's own writes don't trigger the next one.

Options

| Option | Type | Default | Description | | --------------- | ---------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | cwd | string | tsdown's cwd | Where panda.config.ts is looked up, and what its include/exclude globs are relative to. A relative path is resolved against tsdown's cwd. | | configPath | string | discovered upward from cwd | An explicit Panda config file, relative to cwd. | | codegen | boolean | true | Write Panda's generated files before the build starts. See Codegen. | | outdir | string | the Panda config's outdir | Where the generated files are written, relative to cwd; replaces the config's outdir, default import map included. Not tsdown's outDir, which is where the build output goes. | | transform | boolean | true | Rewrite statically resolvable style calls into class names. false only extracts the CSS. See Transform. | | polyfill | boolean | the Panda config's polyfill | Emit the CSS without cascade layers, through Panda's polyfill, which raises the specificity of every rule. See Cascade layers. | | cascadeLayers | boolean | true | Keep Panda's cascade layers. false removes them and orders the rules by layer, leaving every selector's specificity as it was. See Cascade layers. | | fileName | string | "bundle.css" | Name of the stylesheet, written to tsdown's output directory. | | minify | boolean | false | Minify the stylesheet. Lightning CSS processes it either way. | | target | string \| string[] \| false | tsdown's target | Browsers to transform the CSS syntax for, esbuild-style; false disables the transform. See Browser targets. | | lightningcss | Omit<TransformOptions, 'filename' \| 'code'> | — | Options passed through to Lightning CSS. targets wins over target; unusedSymbols is added to the --made-with-panda marker the plugin removes; cssModules is always off, since Panda names the classes. | | inject | boolean | false | Add an import of the stylesheet to the entry chunks that use Panda styles. See Loading the stylesheet. | | exports | boolean \| {nodeCompat?: boolean} | false | Publish the stylesheet as the ./<fileName> export, conditional with a Node.js shim when nodeCompat is set. See Loading the stylesheet. |

@pandacss/vite's cwd, configPath and outdir have the same meaning here. Its transform exists too but is on by default, since a published package should not depend on Panda's runtime where it can avoid it.

An option the plugin doesn't have is an error, not something it ignores — a typo, or an option of a newer @sanity/tsdown-config than the installed plugin supports (cascadeLayers set to anything but a boolean is one). PANDACSS_PLUGIN_OPTION_NAMES exports the names of the options, for hosts that pass options through and want to check them the same way.

Vite plugin

@sanity/pandacss-tsdown-plugin/vite is for packages that build with this plugin and cascadeLayers: false (directly or through the panda option of @sanity/tsdown-config), and also run their Panda sources through the official @pandacss/vite plugin, in a dev server, a Storybook or a Vitest browser suite. Its removeCascadeLayers() is the Vite counterpart of cascadeLayers: false: registered next to @pandacss/vite, it removes the layers from the CSS @pandacss/vite generates with the same pass as the build, so the Vite output cascades like the published bundle.css. It isn't a standalone Vite plugin: it neither replaces nor wraps @pandacss/vite, and tsdown stays a required peer.

The package already has the plugin and tsdown for its build, and @pandacss/vite for its Vite setups. The entry also needs vite, an optional peer dependency of the plugin, so builds that don't use the entry don't install it.

// vite.config.ts
import pandacss from '@pandacss/vite'
import {removeCascadeLayers} from '@sanity/pandacss-tsdown-plugin/vite'
import {defineConfig} from 'vite'

export default defineConfig({
  plugins: [
    pandacss({transform: true}),
    removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities']),
  ],
})
  • The layer names are the ones the Panda config gives its layers: Panda's defaults above, or the names its layers option renames them to, such as u-reset for reset. The names are top-level layer names, which remove their sublayers too: a config that puts Panda's layers under a layer of its own (layers: {base: 'panda.base', …}) passes panda. A dotted name such as panda.base throws, since a sublayer's order depends on the layers it is declared next to.
  • The stylesheet @pandacss/vite fills stays the app's own: a CSS file that declares the layers, such as @layer reset, base, tokens, recipes, utilities;, imported at the top of the app's entry, .storybook/preview.tsx or a Vitest setup file, before anything that loads other CSS. Without layers, Panda's rules compete with the app's other CSS by specificity and source order, so they have to load first, before the <style> elements styled-components adds at runtime, for example. removeCascadeLayers() removes the declaration of the named layers from it too.
  • Only the named layers go, together with their sublayers (Panda's recipes.slots and utilities.compositions): their @layer blocks, and their names in @layer statements. Other layers stay as they are, such as the ones another library's stylesheet declares, or a @layer statement ahead of the declaration that orders other libraries' layers (@layer sui;). That applies to every CSS module, so the names should be the Panda config's own when other CSS on the page uses the same ones, such as Tailwind's base and utilities.
  • It runs after @pandacss/vite, whose transform comes first (order: 'pre'), wherever it is registered, and before Vite's own CSS pipeline (PostCSS, @import inlining, Lightning CSS minification), so the stylesheets the app's stylesheet @imports keep their layers. That holds in vite dev, vite build and Vitest's browser mode alike.

Advanced

  • How modules are selected. The plugin tells rolldown which modules to hand to its transform hook from the config's include/exclude globs (the fast-glob subset Panda configs use: **, *, ?, {a,b}, […]; anything else, and watch mode, let the hook see every module), and always asks Panda's own matcher before touching one. The decision is Panda's either way. The transform hook runs ahead of other plugins' transforms (order: 'pre', like @pandacss/transformer's own bundler plugin), so Panda reads the authored source even when another plugin, such as a React Compiler, lowers JSX.
  • Other hosts. The plugin is a rolldown plugin with two tsdown hooks on top. A host that isn't tsdown can pass what tsdown would through plugin.api.setBuildContext({target, packageName, cwd, outDir}); without it, target has no default, the package name for exports comes from the nearest package.json, and the output directory is not excluded from watching.- Helpers. createCssExport, createConditionalCssExport and insertCssExport build and insert the ./<fileName> export; cssShimFileName, cssShimDtsFileName, cssShimSource and cssShimDtsSource describe the Node.js shim; esbuildTargetToLightningCSS converts esbuild-style targets; resolveCssExportOptions resolves inject/exports. They are exported for hosts that integrate the same CSS export pattern.

Related

License

MIT © Sanity.io