
Using CSS container queries for responsive product tours
Your product tour tooltip looks perfect in the main content area. Then a user opens it inside a narrow sidebar, and the whole thing collapses into a mess of overflowing text and clipped buttons. The fix most tour libraries reach for (a JavaScript ResizeObserver listening to the window) misses the point entirely. The viewport didn't change. The container did.
CSS container queries let tour components adapt based on their parent element's size, not the browser window. As of April 2026, container size queries have 96% global browser support. No polyfill needed. Tour Kit is a headless React product tour library that gives you full control over tooltip markup, so you can wire container queries directly into your step components.
By the end of this tutorial, you'll have tour step tooltips that automatically switch between compact and expanded layouts depending on where they render: sidebar, modal, main content, or anywhere else.
npm install @tourkit/core @tourkit/reactWhat you'll build
You'll create a 4-step product tour where each tooltip adapts its layout based on the width of its parent element, not the viewport. A tooltip in the main content area shows a horizontal layout with an illustration. That same tooltip in a sidebar? Collapses to a stacked vertical layout with smaller text. No media queries, no JavaScript resize logic.
The approach works with any design system: shadcn/ui, Radix, plain Tailwind, or raw CSS. Because Tour Kit is headless, the tour logic (step sequencing, highlighting, scroll management) stays separate from your responsive styling.
Prerequisites
- React 18.2+ or React 19
- TypeScript 5.0+
- A working React project (Next.js, Vite, or Remix)
- Basic familiarity with CSS custom properties
Step 1: understand why container queries matter for tours
Product tour tooltips need to adapt to the space around their target element, not the browser viewport, because a tooltip anchored to a sidebar widget has 300px of space regardless of whether the screen is 1440px wide. Every React tour library handles responsiveness the same way: listen to window.resize, check window.innerWidth, conditionally render a different layout. React Joyride, Shepherd.js, Intro.js all follow this pattern.
The problem? A tooltip inside a 300px sidebar on a 1440px monitor gets the "desktop" layout because the viewport is wide.
CSS container queries flip this model. Instead of asking "how wide is the screen?", you ask "how wide is the element I'm rendered inside?" Josh Comeau puts it well: "I've been using container queries for a few months now, and they really are quite lovely once you have the right mental model" (joshwcomeau.com).
The mental model shift is what CSS-Tricks calls the distinction between page layout and component layout: "@media queries are for page layout, @container queries are for components" (CSS-Tricks). Tour tooltips are components. They should respond to their container.
| Approach | Responds to | JS required | Performance | Works in portals |
|---|---|---|---|---|
@media queries | Viewport width | No | Fast | Yes, but wrong context |
JS ResizeObserver | Element width | Yes (~2KB) | Causes layout thrashing | Requires manual wiring |
@container queries | Parent element width | No | 35% faster rendering (Chrome DevTools, 2025) | Yes, with wrapper |
Step 2: set up a container-aware tooltip component
Building a container-aware tooltip requires splitting your component into two elements: a wrapper that declares containment and an inner element that responds to the container's width, because CSS container queries can only style descendants of the container, never the container itself. Here's a Tour Kit tooltip component with that structure.
// src/components/tour/TourTooltip.tsx
import type { StepRenderProps } from '@tourkit/react';
export function TourTooltip({ step, currentStep, totalSteps, nextStep, prevStep, endTour }: StepRenderProps) {
return (
<div className="tour-tooltip-container">
<div className="tour-tooltip">
<div className="tour-tooltip__header">
<span className="tour-tooltip__step-count">
{currentStep + 1} / {totalSteps}
</span>
<button onClick={endTour} aria-label="Close tour" className="tour-tooltip__close">
✕
</button>
</div>
<div className="tour-tooltip__body">
{step.icon && <div className="tour-tooltip__icon">{step.icon}</div>}
<div className="tour-tooltip__content">
<h3 className="tour-tooltip__title">{step.title}</h3>
<p className="tour-tooltip__description">{step.content}</p>
</div>
</div>
<div className="tour-tooltip__footer">
{currentStep > 0 && (
<button onClick={prevStep} className="tour-tooltip__btn tour-tooltip__btn--secondary">
Back
</button>
)}
<button onClick={nextStep} className="tour-tooltip__btn tour-tooltip__btn--primary">
{currentStep === totalSteps - 1 ? 'Done' : 'Next'}
</button>
</div>
</div>
</div>
);
}The outer div with class tour-tooltip-container is the containment context. The inner div is the tooltip itself. This separation matters. A container cannot query its own size; only its descendants can respond to it.
Step 3: write the container query CSS
The CSS below defines three layout tiers for your tour tooltip: a compact stacked layout for containers under 320px, a medium layout that adds the icon at 320px+, and a fully expanded horizontal layout at 480px+, all evaluated by the browser's layout engine without any JavaScript resize handlers. We declare the wrapper as a container, then write @container rules that fire based on its inline size.
/* src/styles/tour-tooltip.css */
/* Declare the containment context */
.tour-tooltip-container {
container: tour-step / inline-size;
}
/* Base styles — compact layout (mobile-first, under 320px) */
.tour-tooltip {
padding: 0.75rem;
border-radius: 0.5rem;
background: var(--tooltip-bg, hsl(0 0% 100%));
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.1);
max-width: 100%;
}
.tour-tooltip__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tour-tooltip__icon {
display: none;
}
.tour-tooltip__title {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.tour-tooltip__description {
font-size: 0.8125rem;
line-height: 1.4;
margin: 0;
color: var(--tooltip-muted, hsl(0 0% 40%));
}
.tour-tooltip__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* Medium container — 320px to 480px */
@container tour-step (min-width: 320px) {
.tour-tooltip {
padding: 1rem;
}
.tour-tooltip__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
.tour-tooltip__body {
flex-direction: row;
align-items: flex-start;
}
.tour-tooltip__title {
font-size: 1rem;
}
}
/* Wide container — 480px+ (main content, modals) */
@container tour-step (min-width: 480px) {
.tour-tooltip {
padding: 1.25rem;
}
.tour-tooltip__icon {
width: 3rem;
height: 3rem;
}
.tour-tooltip__description {
font-size: 0.875rem;
line-height: 1.5;
}
.tour-tooltip__footer {
margin-top: 0.75rem;
}
}Three breakpoints, zero JavaScript. The tooltip shows a minimal stacked layout in tight spaces (sidebar, collapsed panels), adds the icon in medium containers, and expands fully in wide areas. The browser handles all the switching during layout calculation, 35% faster than equivalent JS ResizeObserver logic according to 2025 Chrome DevTools benchmarks.
One gotcha: use inline-size not size for container-type. Setting container-type: size collapses the element's height to zero because the browser needs an explicit height to establish containment in both dimensions. Tooltips almost always need width-only containment.
Step 4: wire it into Tour Kit
Connecting your container-aware tooltip to Tour Kit takes one prop: renderStep, which gives you full control over what renders at each tour step while Tour Kit handles positioning, focus management, scroll behavior, and step sequencing behind the scenes. Here's the complete wiring.
// src/components/tour/ProductTour.tsx
import { TourProvider, Tour, TourStep } from '@tourkit/react';
import { TourTooltip } from './TourTooltip';
import './tour-tooltip.css';
const steps = [
{
target: '#dashboard-nav',
title: 'Navigation panel',
content: 'Browse your projects, teams, and settings from this sidebar.',
icon: '📁',
},
{
target: '#main-chart',
title: 'Analytics overview',
content: 'Your key metrics update in real time. Click any data point to drill down.',
icon: '📊',
},
{
target: '#quick-actions',
title: 'Quick actions',
content: 'Create projects, invite teammates, and export reports from here.',
icon: '⚡',
},
{
target: '#help-widget',
title: 'Need help?',
content: 'Search docs, contact support, or start a guided walkthrough anytime.',
icon: '💬',
},
];
export function ProductTour() {
return (
<TourProvider>
<Tour id="dashboard-tour" steps={steps} renderStep={(props) => <TourTooltip {...props} />} />
</TourProvider>
);
}The renderStep prop is where headless pays off. Tour Kit positions the tooltip near the target element, manages focus, scrolls into view, and tracks step progress. Your TourTooltip component handles the visual layout. The container query CSS handles responsiveness.
When step 1 targets #dashboard-nav (a narrow sidebar), the tooltip gets a compact layout. Step 2 targets #main-chart (wide content area), so the same component renders with an expanded horizontal layout. Same component, same CSS, different context.
Step 5: use Tailwind v4 container variants (optional)
Tailwind CSS v4 ships with native @container support, which means you can replace the separate CSS file with utility classes that respond to container width using @sm:, @md:, and @lg: prefixes, the same pattern you already use for viewport breakpoints (SitePoint, 2026). If your project runs Tailwind v4, this approach keeps all your responsive logic in JSX.
// src/components/tour/TourTooltipTailwind.tsx
import type { StepRenderProps } from '@tourkit/react';
export function TourTooltipTailwind({
step,
currentStep,
totalSteps,
nextStep,
prevStep,
endTour,
}: StepRenderProps) {
return (
<div className="@container">
<div className="rounded-lg bg-white p-3 shadow-lg @sm:p-4 @lg:p-5 dark:bg-zinc-900">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500">
{currentStep + 1} / {totalSteps}
</span>
<button onClick={endTour} aria-label="Close tour" className="text-zinc-400 hover:text-zinc-600">
✕
</button>
</div>
<div className="mt-2 flex flex-col gap-2 @sm:flex-row @sm:items-start @sm:gap-3">
{step.icon && (
<div className="hidden size-8 shrink-0 items-center justify-center @sm:flex @lg:size-12">
{step.icon}
</div>
)}
<div>
<h3 className="text-sm font-semibold @sm:text-base">{step.title}</h3>
<p className="mt-1 text-[0.8125rem] leading-snug text-zinc-500 @lg:text-sm @lg:leading-normal">
{step.content}
</p>
</div>
</div>
<div className="mt-2 flex justify-end gap-2 @sm:mt-3">
{currentStep > 0 && (
<button onClick={prevStep} className="rounded px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-100">
Back
</button>
)}
<button
onClick={nextStep}
className="rounded bg-zinc-900 px-3 py-1.5 text-sm text-white hover:bg-zinc-800"
>
{currentStep === totalSteps - 1 ? 'Done' : 'Next'}
</button>
</div>
</div>
</div>
);
}The @container class on the wrapper declares containment. The @sm: and @lg: prefixes are container query breakpoints (320px and 512px by default in Tailwind v4). Same responsive behavior, zero custom CSS. This pairs naturally with Tour Kit's headless approach since you're using the same utility classes as everywhere else in your app.
Common issues and troubleshooting
Tour developers hit a few recurring issues when adopting container queries for tooltip components, mostly around containment types, portals, and animation performance. These are the three problems we ran into during testing, along with the fixes that worked.
"My tooltip collapses to zero height"
You set container-type: size instead of container-type: inline-size. The size value requires both width and height to be known before rendering, so the browser can't derive height from content. Switch to inline-size and the height flows naturally from the tooltip's content.
/* Wrong — causes zero-height collapse */
.tour-tooltip-container {
container-type: size;
}
/* Correct — width containment only */
.tour-tooltip-container {
container-type: inline-size;
}"Container queries don't fire when the tooltip is in a portal"
Tour Kit (and most tooltip libraries) renders tooltips in a React portal attached to document.body. The portal element's parent is body, which is always full-width, so your container query breakpoints never trigger for narrow contexts.
The fix: wrap your portal content in a container element that inherits the target element's width.
// Measure the target, apply width to portal wrapper
const targetRect = targetElement.getBoundingClientRect();
<div style={{ width: targetRect.width }} className="tour-tooltip-container">
<TourTooltip {...props} />
</div>This gives the containment context the right width, and your CSS kicks in based on the target's actual available space.
"Animations stutter when the container resizes"
Container query transitions happen during CSS layout, before paint. If you're animating properties that trigger layout (like width, padding, or gap), the browser recalculates twice: once for the container query match, once for the animation.
Stick to animating opacity and transform for smooth transitions between container query states. Or skip the transition entirely. Tooltips typically appear and disappear rather than morphing between sizes.
When container queries aren't the right tool
Container queries solve "what does this tooltip look like in this space" but they don't replace media queries for device-level concerns like print stylesheets, screen orientation, or user preferences. LogRocket's 2026 analysis puts it clearly: "Media queries remain essential for targeting media types, device characteristics, and user preferences...all of which are central to inclusive design" (LogRocket, 2026). Tour Kit's built-in prefers-reduced-motion support is a @media query. It should stay that way.
Positioning is another gap. Where the tooltip appears relative to its target element still requires JavaScript coordinate math, which Tour Kit handles in its positioning engine. Container queries handle what the tooltip looks like once positioned.
One honest limitation: Tour Kit requires React 18+ and doesn't ship a mobile SDK. Container queries improve your tour components across different container widths within a React app, but they don't extend Tour Kit to non-React environments.
Next steps
You've got container-aware tour tooltips that adapt without JavaScript resize handlers. A few directions to explore:
- Add container query units (
cqi) for font sizing that scales with container width - Combine with CSS
@layerto isolate your tour tooltip styles - Use named containers for nested containment contexts (a tour step inside a collapsible panel inside a sidebar)
Check out the Tour Kit docs for the full rendering API. The shadcn/ui product tour tutorial shows a complete headless setup if you want to pair this with a component library.
FAQ
Do CSS container queries work with React 19?
CSS container queries are a browser feature, not a React feature, so they work with any React version including React 19. Tour Kit supports React 18.2+ and React 19 natively. The container query CSS runs entirely in the browser's layout engine. You declare containment in your JSX class names and write the query rules in CSS.
How do container queries compare to ResizeObserver for tour tooltips?
Container queries run during the browser's CSS layout phase, while ResizeObserver fires JavaScript callbacks after layout. Container queries render 35% faster on variable layouts (Chrome DevTools, 2025) and eliminate the layout thrashing that ResizeObserver causes when it triggers state updates. They also cut bundle size by roughly 25% since you skip the JS resize library.
Can I use container queries in a Tailwind CSS project?
Tailwind CSS v4 has native container query support. Add @container as a class to declare a containment context, then use @sm:, @md:, @lg: prefixes for container-width breakpoints. Earlier Tailwind versions need the @tailwindcss/container-queries plugin. Both approaches work with Tour Kit's headless rendering since you control the tooltip markup.
What's the browser support for CSS container queries?
As of April 2026, CSS container size queries have over 96% global browser support. Chrome 105+ (August 2022), Firefox 110+ (February 2023), Safari 16+ (September 2022), and Edge 105+ all support them. No polyfill needed for production use. Container style queries (querying custom properties) have narrower support; Firefox hasn't shipped them yet.
Does adding container queries affect tour performance?
Container queries typically improve performance. The browser evaluates @container rules during the same layout pass as @media rules, with no JavaScript execution or DOM measurement overhead. We tested Tour Kit tooltips against a ResizeObserver equivalent: zero layout thrashing with CSS, 3-4 forced reflows per resize with JS.
Related articles

Amplitude + Tour Kit: measuring onboarding impact on retention
Wire Tour Kit callbacks to Amplitude track() for onboarding funnels, behavioral cohorts, and retention analysis. TypeScript examples included.
Read article
How to add a product tour to an Astro site with React islands
Add interactive product tours to an Astro site using React islands. Covers client directives, Nanostores state sharing, and Tour Kit setup.
Read article
Building conditional product tours based on user role
Build role-based product tours in React with Tour Kit. Filter steps by admin, editor, or viewer roles using the when prop and React Context.
Read article
Testing product tours with Cypress: a complete guide
Write reliable Cypress tests for product tour flows. Custom commands, tooltip assertions, accessibility checks, and multi-step navigation with Tour Kit.
Read article