Skip to main content
userTourKit
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

  1. Always handle keyboard navigation - Use useKeyboardNavigation or implement your own
  2. Implement focus trapping - Required for accessibility
  3. Add ARIA attributes - Use role="dialog" and aria-modal="true"
  4. Handle edge cases - Check isFirstStep, isLastStep, and totalSteps
  5. Test with screen readers - Ensure announcements work correctly
Free & open source

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.