ReactHeadless
Headless Examples
Headless component examples: build custom tooltip cards, overlays, and navigation using userTourKit render props
domidex01Published
Learn how to build fully customized tour experiences using headless components.
Custom Card with Tailwind
Build a completely custom tour card using Tailwind CSS:
import { HeadlessTourCard } from '@tour-kit/react/headless'
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
function CustomTailwindCard() {
return (
<HeadlessTourCard>
{({
currentStep,
currentStepIndex,
totalSteps,
next,
prev,
skip,
complete,
isFirstStep,
isLastStep,
floatingStyles,
refs,
}) => (
<div
ref={refs.setFloating}
style={floatingStyles}
className="bg-white rounded-xl shadow-2xl border border-gray-200 w-80 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gradient-to-r from-indigo-500 to-purple-500">
<span className="text-white/80 text-sm font-medium">
Step {currentStepIndex + 1} of {totalSteps}
</span>
<button
onClick={skip}
className="text-white/80 hover:text-white transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-5">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{currentStep?.content?.title}
</h3>
<p className="text-gray-600 text-sm leading-relaxed">
{currentStep?.content?.description}
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 bg-gray-50 border-t">
<button
onClick={prev}
disabled={isFirstStep}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600
hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
Back
</button>
<button
onClick={isLastStep ? complete : next}
className="flex items-center gap-1 px-4 py-1.5 text-sm text-white
bg-indigo-500 rounded-lg hover:bg-indigo-600 transition-colors"
>
{isLastStep ? 'Finish' : 'Next'}
{!isLastStep && <ChevronRight size={16} />}
</button>
</div>
</div>
)}
</HeadlessTourCard>
)
}Custom Overlay with Animations
Create an animated spotlight overlay:
import { useSpotlight } from '@tour-kit/core'
import { motion, AnimatePresence } from 'framer-motion'
function AnimatedOverlay() {
const { isVisible, targetRect, overlayStyle } = useSpotlight()
return (
<AnimatePresence>
{isVisible && targetRect && (
<>
{/* Overlay background */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
/>
{/* Spotlight cutout */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
style={{
position: 'fixed',
left: targetRect.left - 8,
top: targetRect.top - 8,
width: targetRect.width + 16,
height: targetRect.height + 16,
borderRadius: 8,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
}}
className="z-40 pointer-events-none"
/>
{/* Pulsing ring */}
<motion.div
animate={{
scale: [1, 1.05, 1],
opacity: [0.5, 0.8, 0.5],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
position: 'fixed',
left: targetRect.left - 12,
top: targetRect.top - 12,
width: targetRect.width + 24,
height: targetRect.height + 24,
borderRadius: 12,
border: '2px solid rgb(99, 102, 241)',
}}
className="z-40 pointer-events-none"
/>
</>
)}
</AnimatePresence>
)
}Minimal Progress Bar
A simple, minimal progress indicator:
import { useTour } from '@tour-kit/core'
function MinimalProgress() {
const { isActive, currentStepIndex, totalSteps, progress } = useTour()
if (!isActive) return null
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50">
<div className="flex items-center gap-2 bg-white/90 backdrop-blur-sm
rounded-full px-4 py-2 shadow-lg">
{/* Step dots */}
<div className="flex gap-1.5">
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full transition-all duration-300 ${
i <= currentStepIndex
? 'bg-indigo-500 scale-110'
: 'bg-gray-300'
}`}
/>
))}
</div>
{/* Percentage */}
<span className="text-xs font-medium text-gray-600 ml-2">
{Math.round(progress)}%
</span>
</div>
</div>
)
}Mobile-First Card
A card optimized for mobile with swipe gestures:
import { HeadlessTourCard } from '@tour-kit/react/headless'
import { useSwipeable } from 'react-swipeable'
function MobileCard() {
return (
<HeadlessTourCard>
{({ currentStep, next, prev, skip, isFirstStep, isLastStep }) => {
const handlers = useSwipeable({
onSwipedLeft: () => !isLastStep && next(),
onSwipedRight: () => !isFirstStep && prev(),
preventScrollOnSwipe: true,
})
return (
<div
{...handlers}
className="fixed bottom-0 left-0 right-0 z-50
bg-white rounded-t-3xl shadow-2xl p-6 pb-8
transform transition-transform"
>
{/* Drag handle */}
<div className="w-12 h-1 bg-gray-300 rounded-full mx-auto mb-4" />
{/* Content */}
<h3 className="text-xl font-bold text-gray-900 mb-2">
{currentStep?.content?.title}
</h3>
<p className="text-gray-600 mb-6">
{currentStep?.content?.description}
</p>
{/* Swipe hint */}
<p className="text-center text-sm text-gray-400 mb-4">
Swipe left or right to navigate
</p>
{/* Skip button */}
<button
onClick={skip}
className="w-full py-3 text-gray-500 hover:text-gray-700"
>
Skip tour
</button>
</div>
)
}}
</HeadlessTourCard>
)
}Using with shadcn/ui
Integrate with shadcn/ui components:
import { HeadlessTourCard } from '@tour-kit/react/headless'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
function ShadcnCard() {
return (
<HeadlessTourCard>
{({
currentStep,
currentStepIndex,
totalSteps,
progress,
next,
prev,
skip,
isFirstStep,
isLastStep,
floatingStyles,
refs,
}) => (
<Card
ref={refs.setFloating}
style={floatingStyles}
className="w-80"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Step {currentStepIndex + 1} of {totalSteps}
</span>
<Button variant="ghost" size="sm" onClick={skip}>
Skip
</Button>
</div>
<Progress value={progress} className="h-1" />
</CardHeader>
<CardContent>
<CardTitle className="mb-2">
{currentStep?.content?.title}
</CardTitle>
<CardDescription>
{currentStep?.content?.description}
</CardDescription>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={prev}
disabled={isFirstStep}
>
Previous
</Button>
<Button onClick={isLastStep ? skip : next}>
{isLastStep ? 'Finish' : 'Next'}
</Button>
</CardFooter>
</Card>
)}
</HeadlessTourCard>
)
}Building from Hooks
For maximum control, build directly with hooks:
import {
useTour,
useStep,
useSpotlight,
useFocusTrap,
useKeyboardNavigation,
} from '@tour-kit/core'
import { useFloating, offset, flip, shift } from '@floating-ui/react'
function FullyCustomTour({ tourId }: { tourId: string }) {
const tour = useTour(tourId)
const step = useStep()
const spotlight = useSpotlight()
const { containerRef, activate, deactivate } = useFocusTrap(tour.isActive)
// Set up floating UI
const { refs, floatingStyles } = useFloating({
placement: step.currentStep?.placement || 'bottom',
middleware: [offset(12), flip(), shift({ padding: 8 })],
})
// Sync floating reference with step target
useEffect(() => {
if (step.targetElement) {
refs.setReference(step.targetElement)
}
}, [step.targetElement, refs])
// Keyboard navigation
useKeyboardNavigation({
enabled: tour.isActive,
nextKeys: ['ArrowRight', 'Enter'],
prevKeys: ['ArrowLeft'],
exitKeys: ['Escape'],
})
// Focus trap
useEffect(() => {
if (tour.isActive) {
activate()
} else {
deactivate()
}
}, [tour.isActive, activate, deactivate])
if (!tour.isActive) return null
return (
<>
{/* Your custom overlay */}
{spotlight.isVisible && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={tour.skip}
/>
)}
{/* Your custom card */}
<div
ref={(node) => {
refs.setFloating(node)
if (containerRef) containerRef.current = node
}}
style={floatingStyles}
className="bg-white rounded-lg shadow-xl p-4 z-50"
role="dialog"
aria-modal="true"
aria-labelledby="tour-title"
>
<h3 id="tour-title">{step.currentStep?.content?.title}</h3>
<p>{step.currentStep?.content?.description}</p>
<div className="flex gap-2 mt-4">
<button onClick={tour.prev} disabled={tour.isFirstStep}>
Back
</button>
<button onClick={tour.isLastStep ? tour.complete : tour.next}>
{tour.isLastStep ? 'Done' : 'Next'}
</button>
</div>
</div>
</>
)
}Tips for Custom Implementations
- Always handle keyboard navigation - Use
useKeyboardNavigationor implement your own - Implement focus trapping - Required for accessibility
- Add ARIA attributes - Use
role="dialog"andaria-modal="true" - Handle edge cases - Check
isFirstStep,isLastStep, andtotalSteps - Test with screen readers - Ensure announcements work correctly
Related
<HeadlessTourCard>and<HeadlessTourOverlay>— the primitives composed in these examples.- Headless overview — when to reach for headless vs. styled components.
useKeyboardanduseFocusTrap— keyboard + focus a11y, mandatory for custom UIs.- Accessibility guide — WCAG 2.1 AA checklist your custom UI must satisfy.
- Headless custom example — full working app built on these patterns.
Ship onboarding, not config.
npm i @tour-kit/core is MIT and free. The Pro packages work unlicensed too — a one-time $99 license removes the production watermark when you ship.
MIT-licensed — no signup, no credit card. Pay once, only when you ship.