
Tree-shaking product tour libraries: what actually gets removed?
You add a product tour library to your React app, import one hook, and assume the bundler strips out everything you didn't use. Sometimes it does. Often it doesn't. The difference between a 3KB import and a 34KB import comes down to how the library was built, not how smart your bundler is.
We ran Vite's bundle analyzer against five tour libraries, importing a single component from each, and measured what survived tree-shaking. The results were uneven. React Joyride shipped 31KB of code regardless of what we imported. Tour Kit shipped 2.9KB for the same single-hook import. Driver.js landed at 5KB. Shepherd.js brought in 22KB.
We built Tour Kit, so take those numbers with appropriate skepticism. Every measurement below is reproducible using npx vite-bundle-visualizer. We'll show you exactly how to run the same tests.
npm install @tourkit/core @tourkit/reactWhat is tree-shaking and why does it matter for tour libraries?
Tree-shaking is a dead-code elimination technique that bundlers like Vite, webpack, and Rollup use to strip unused exports from your production JavaScript. It analyzes the static import/export graph of ES modules, marks which exports are referenced, and drops everything else. As of April 2026, every major bundler enables tree-shaking by default, but the technique only works when the library cooperates. Tour libraries are a strong test case because most apps use a small fraction of a tour library's API.
Smashing Magazine's tree-shaking reference guide explains the core mechanic well. Bundlers treat each export as a node in a dependency graph. No import path to a node? Gone. But side effects, CommonJS, and barrel files can block elimination entirely.
Why does this matter for tours specifically? Tour code loads for every user but runs for a fraction of sessions. A failed tree-shake means you pay for the positioning engine, analytics hooks, and tooltip variants you never render. Every page load. The HTTP Archive's 2025 Web Almanac reports the median page ships 509KB of JavaScript. A 34KB library that should have been 5KB wastes 5.7% of that budget.
How tree-shaking actually works in tour libraries
Whether a tour library tree-shakes well depends on three factors that the library author controls: module format (ESM vs CJS), the sideEffects declaration in package.json, and how the library structures its exports. Get any one of these wrong and the bundler conservatively keeps everything, regardless of what your app actually imports. Most tour libraries get at least one wrong.
ESM is the prerequisite
Tree-shaking requires ES module syntax. import { useTour } from '@tourkit/core' gives the bundler a static graph to analyze. const { useTour } = require('@tourkit/core') does not. CommonJS require() is dynamic, so the bundler can't prove at build time which exports are unused.
As of 2026, most major React libraries ship ESM builds. But some tour libraries still ship CJS-only or ship ESM builds that re-export from CJS internal modules, which defeats the purpose. Check your library's package.json for "type": "module" and an exports map with "import" conditions.
The sideEffects flag
The "sideEffects": false field in package.json tells the bundler: "every file in this package is pure. If you don't import from it, you can safely drop it." Without this flag, bundlers must assume any module might modify global state when loaded, so they keep everything.
Webpack's tree-shaking documentation explicitly calls this out: "If all code within a module is side-effect free, we can simply mark the property as false to inform webpack that it can safely prune unused exports."
Here's what the declaration looks like in practice:
// package.json
{
"name": "@tourkit/core",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}Export structure and barrel files
A barrel file re-exports everything from a package through a single index.ts. When done carelessly, importing one function from a barrel pulls in the entire module graph because the bundler can't prove the other exports are side-effect free.
The fix: explicit named exports combined with sideEffects: false. Tour Kit's core package exports 60+ symbols through a barrel, but because every sub-module is marked side-effect free, the bundler can trace exactly which files are needed for any given import and drop the rest.
// @tour-kit/core - src/index.ts (simplified)
// Type-only exports are always eliminated
export type { TourStep, TourState, TourOptions } from './types'
// Runtime exports are individually shakeable
export { useTour, useStep, useSpotlight } from './hooks'
export { calculatePosition, scrollIntoView } from './utils'
export { TourProvider, TourKitProvider } from './context'Importing just useTour pulls in the tour context and state management hooks. The positioning engine (calculatePosition), scroll utilities, storage adapters, branch logic, and throttle utilities all get eliminated.
What we measured: five libraries through Vite's analyzer
We tested five tour libraries by creating a minimal Vite 6 + React 19 + TypeScript 5.7 project, importing one hook or component from each, and running npx vite-bundle-visualizer to measure gzipped output. Each test used the minimum viable import to render a basic 3-step tour. All measurements are from production builds taken in April 2026. The methodology section at the end of this article has the full setup details.
| Library | Full bundle (gzipped) | Single-hook import (gzipped) | Tree-shaking savings | sideEffects: false | ESM build |
|---|---|---|---|---|---|
| Tour Kit (core + react) | 8.1KB | 2.9KB | 64% | ✅ | ✅ |
| Driver.js 1.x | 5.1KB | 5.0KB | 2% | ❌ | ✅ |
| Shepherd.js 14.x | 25KB | 22KB | 12% | ❌ | ✅ |
| React Joyride 2.x | 34KB | 31KB | 9% | ❌ | ⚠️ partial |
| Intro.js 7.x | 29KB | 27KB | 7% | ❌ | ⚠️ partial |
The pattern is clear. Libraries without sideEffects: false barely tree-shake at all. Driver.js is small enough that it doesn't matter much. But React Joyride and Intro.js carry 27-31KB into your bundle regardless of what you actually import.
Why React Joyride resists tree-shaking
React Joyride pulls in 603K weekly downloads as of April 2026, making it the most popular tour library on npm. Its architecture predates modern tree-shaking conventions, and three specific issues prevent the bundler from eliminating unused code. Understanding these patterns helps you evaluate any library, not just Joyride.
First, class-based React components. Joyride ships class components whose constructor and componentDidMount lifecycle methods can contain side effects. Bundlers must assume they're impure.
Second, no "sideEffects": false declaration. Without that flag, Vite and webpack keep every module in the package.
Third, a single entry point that bundles tooltips, scroll handling, spotlight overlay, and event system together. No subpath exports. You get everything or nothing.
To be fair, Joyride was built before sideEffects was widely adopted. It works well for its use case. If you need pre-styled tour components and 34KB fits your budget, tree-shaking is irrelevant.
How Tour Kit achieves 64% tree-shaking reduction
Tour Kit eliminates 64% of its own code on a single-hook import because of four deliberate architectural decisions made at the package design level: multiple entry points, explicit type exports, tsup's splitting option, and zero runtime dependencies in the core package. No single flag does this alone. The caveat: Tour Kit is React 18+ only, has no visual builder, and its community is younger than Joyride's or Shepherd's.
Multiple entry points
The @tour-kit/react package exposes four separate entry points through its package.json exports map:
{
"exports": {
".": "./dist/index.js",
"./headless": "./dist/headless.js",
"./lazy": "./dist/lazy.js",
"./tailwind": "./dist/tailwind/index.js"
}
}Importing from @tourkit/react/headless only loads the headless tour components. The styled components, Tailwind utilities, and lazy-loading wrappers never enter your bundle. This is subpath-level tree-shaking, one level coarser than export-level shaking, but more reliable because bundlers handle it without any heuristics.
Explicit type exports
Tour Kit separates type exports from runtime exports using TypeScript's export type syntax:
// These are compile-time only - zero bytes in production
export type { TourStep, TourState, TourOptions } from './types'
// These are the actual runtime code
export { useTour } from './hooks'TypeScript types contribute zero bytes to production bundles, but only if you use export type. A regular export { TourStep } on a type alias still creates a reference the bundler has to trace.
tsup with treeshake and splitting
Tour Kit uses tsup (an esbuild wrapper) configured with both treeshake: true and splitting: true. The splitting option generates separate chunks for shared code between entry points, so importing from ./headless and ./lazy doesn't duplicate their shared dependency on @tour-kit/core.
// tsup.config.ts
export default defineConfig({
entry: {
index: 'src/index.ts',
headless: 'src/headless.ts',
lazy: 'src/lazy.tsx',
'tailwind/index': 'src/tailwind/index.ts',
},
treeshake: true,
splitting: true,
format: ['cjs', 'esm'],
// ...
})Zero runtime dependencies in core
@tour-kit/core has no dependencies field at all. React and React DOM are peer dependencies. This means tree-shaking only needs to analyze Tour Kit's own code, not a chain of third-party modules that may or may not be side-effect free. The fewer modules in the dependency graph, the more effective tree-shaking becomes.
How to verify tree-shaking in your own project
Bundlephobia reports total package size but can't tell you what survives tree-shaking in your specific build. The only reliable way to measure actual bundle cost is to run your own analysis with rollup-plugin-visualizer or webpack-bundle-analyzer. Here's the 3-step process we used for the measurements in this article. Takes about five minutes.
Step 1: install vite-bundle-visualizer
npm install -D rollup-plugin-visualizerAdd it to your Vite config:
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
gzipSize: true,
filename: 'bundle-analysis.html',
}),
],
})Step 2: build and open the treemap
npx vite buildThis generates bundle-analysis.html in your project root. Open it in a browser. You'll see a treemap where each rectangle's area corresponds to its gzipped size in the bundle. Find your tour library and see how much of it survived.
Step 3: compare with and without your import
Comment out your tour library import, rebuild, and compare the total bundle size. The difference is the actual cost of the library after tree-shaking. If it's close to bundlephobia's reported size, tree-shaking isn't working.
For webpack users, webpack-bundle-analyzer provides the same visualization:
npm install -D webpack-bundle-analyzerCommon mistakes that break tree-shaking
Even when a library is perfectly structured for tree-shaking, your application code can undo the optimization. The three most common mistakes are wildcard re-exports from wrapper modules, dynamic imports that pull in the full package instead of a subpath, and missing sideEffects declarations in your own code. Each one forces the bundler to keep modules it would otherwise drop.
Re-exporting everything from a wrapper module
// DON'T: This pulls in the entire library
export * from '@tourkit/react'
// DO: Export only what you use
export { TourProvider, useTour } from '@tourkit/react'Dynamic imports that reference the full package
// DON'T: Dynamic import of the full package
const TourLib = await import('@tourkit/react')
// DO: Dynamic import of a subpath
const { TourProvider } = await import('@tourkit/react/headless')Missing or incorrect sideEffects in your own code
If your wrapper module around a tour library has side effects (global CSS imports, polyfills, global state initialization), mark them explicitly:
{
"sideEffects": ["*.css", "./src/setup.ts"]
}This tells the bundler which files are impure while letting it eliminate the rest.
The 10-package architecture advantage
The most reliable form of tree-shaking isn't tree-shaking at all. It's never downloading the code in the first place. Tour Kit splits its functionality across 10 independent npm packages, each with its own package.json, sideEffects: false, and entry points. If you only need tours, install @tourkit/core and @tourkit/react. Checklists, surveys, analytics, and announcements stay out of node_modules entirely.
The Theodo engineering blog calls this "package-level tree-shaking." It's more reliable than export-level elimination because it doesn't depend on bundler heuristics. You can't accidentally include code that was never installed.
Monolithic libraries bundle surveys, checklists, and analytics together. Your bundler has to figure out what's unused. Sometimes it gets it right. Often it doesn't.
What to look for when evaluating tour libraries
Five fields in a library's package.json predict whether it will tree-shake well in your production build. You can check all of them in under 30 seconds on npm or GitHub, before installing anything. Libraries missing two or more of these signals will likely ship their full bundle regardless of what you import.
"sideEffects": falseor an explicit list. No declaration means the bundler keeps everything."type": "module"and anexportsmap with"import"conditions. This confirms ESM support.- Multiple entry points (subpath exports). More entry points means finer-grained code elimination.
- Zero or minimal runtime dependencies. Each dependency is another module the bundler must analyze for side effects.
- Separate type exports.
export typeprevents TypeScript types from creating runtime references.
FAQ
Does tree-shaking work with CommonJS modules?
Tree-shaking does not work with CommonJS (require()) modules. It requires static import/export syntax so the bundler can determine unused exports at build time. A CJS-only tour library ships its full bundle regardless. Vite, webpack 5, and Rollup all support ESM tree-shaking by default, but none can shake CJS.
How much does sideEffects: false actually save?
The sideEffects: false flag was the single largest factor in our tests. Libraries without it showed 2-12% reduction. Tour Kit, which declares it on all 10 packages, showed 64% reduction for a single-hook import. The flag tells the bundler that unused modules can be safely dropped.
Can I tree-shake React Joyride by importing specific modules?
React Joyride doesn't expose subpath exports, so you can't import individual components. The entire 34KB library enters your bundle. Dynamic imports with React.lazy() defer loading but don't eliminate code. It still downloads eventually.
What's the difference between tree-shaking and code splitting?
Tree-shaking removes code you never use anywhere. Code splitting loads code in separate chunks at different times. They're complementary: tree-shake first to eliminate dead code, then code-split so each route loads only what it needs. Tour Kit supports both via its @tourkit/react/lazy entry point.
How do I check if a library tree-shakes well before installing it?
Open the library's package.json on npm. Look for sideEffects: false, type: module, and an exports map with subpath entries. Then run npx vite-bundle-visualizer in a test project to measure actual output. Bundlephobia doesn't account for tree-shaking.
Measurements taken April 2026 using Vite 6.2, React 19.2, TypeScript 5.7, on an M2 MacBook Air. All gzipped sizes measured via vite build with default production settings. Source projects and bundle analysis reports available on request.
Related articles

Web components vs React components for product tours
Compare web components and React for product tours. Shadow DOM limits, state management gaps, and why framework-specific wins.
Read article
Animation performance in product tours: requestAnimationFrame vs CSS
Compare requestAnimationFrame and CSS animations for product tour tooltips. Learn the two-layer architecture that keeps tours at 60fps without jank.
Read article
Building ARIA-compliant tooltip components from scratch
Build an accessible React tooltip with role=tooltip, aria-describedby, WCAG 1.4.13 hover persistence, and Escape dismissal. Includes working TypeScript code.
Read article
How we benchmark React libraries: methodology and tools
Learn the 5-axis framework we use to benchmark React libraries. Covers bundle analysis, runtime profiling, accessibility audits, and statistical rigor.
Read article