TourKit
@tour-kit/announcementsHeadless

Headless Components

Headless announcement components: unstyled Modal, Toast, Banner, Slideout, and Spotlight with render props for custom UIs

Headless Components

Completely unstyled components that provide announcement logic and state through render props. Perfect for building custom UI that matches your design system.

Why Headless?

Headless components give you:

  • Full design control - No CSS to override
  • Framework flexibility - Use with any CSS solution
  • Logic reuse - Announcement behavior without UI opinions
  • Accessibility - Built-in ARIA and keyboard support
  • Type safety - Full TypeScript support

Available Components


Basic Pattern

All headless components use a render props pattern:

import { HeadlessModal } from '@tour-kit/announcements/headless';

<HeadlessModal
  id="announcement-id"
  render={({ isVisible, config, state, hide, dismiss }) => {
    if (!isVisible) return null;

    return (
      <div className="my-custom-modal">
        <h2>{config.title}</h2>
        <p>{config.description}</p>
        <button onClick={hide}>Close</button>
        <button onClick={dismiss}>Don't Show Again</button>
      </div>
    );
  }}
/>

Shared Render Props

All headless components provide these props:

interface HeadlessRenderProps {
  // State
  isVisible: boolean;
  isActive: boolean;
  isDismissed: boolean;
  canShow: boolean;
  viewCount: number;

  // Config & State
  config: AnnouncementConfig;
  state: AnnouncementState;

  // Methods
  show: () => void;
  hide: () => void;
  dismiss: (reason?: DismissalReason) => void;
  complete: () => void;
}

Quick Example

import { HeadlessModal } from '@tour-kit/announcements/headless';

function CustomAnnouncement() {
  return (
    <HeadlessModal
      id="welcome"
      render={({ isVisible, config, hide, dismiss }) => {
        if (!isVisible) return null;

        return (
          <div className="fixed inset-0 z-50 flex items-center justify-center">
            {/* Overlay */}
            <div
              className="absolute inset-0 bg-black/50"
              onClick={hide}
            />

            {/* Content */}
            <div className="relative bg-white rounded-lg p-6 max-w-md">
              <h2 className="text-2xl font-bold">{config.title}</h2>
              <p className="mt-2 text-gray-600">{config.description}</p>

              {config.media?.type === 'image' && (
                <img
                  src={config.media.src}
                  alt={config.media.alt}
                  className="mt-4 rounded"
                />
              )}

              <div className="mt-6 flex gap-2">
                {config.primaryAction && (
                  <button
                    onClick={() => {
                      config.primaryAction?.onClick();
                      dismiss('primary_action');
                    }}
                    className="px-4 py-2 bg-blue-600 text-white rounded"
                  >
                    {config.primaryAction.label}
                  </button>
                )}

                <button
                  onClick={hide}
                  className="px-4 py-2 border rounded"
                >
                  Close
                </button>
              </div>
            </div>
          </div>
        );
      }}
    />
  );
}

With CSS-in-JS

import styled from '@emotion/styled';
import { HeadlessModal } from '@tour-kit/announcements/headless';

const Overlay = styled.div`
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 50;
`;

const Modal = styled.div`
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 2rem;
  border-radius: 0.5rem;
  z-index: 51;
`;

function EmotionModal() {
  return (
    <HeadlessModal
      id="announcement"
      render={({ isVisible, config, hide }) => {
        if (!isVisible) return null;

        return (
          <>
            <Overlay onClick={hide} />
            <Modal>
              <h2>{config.title}</h2>
              <p>{config.description}</p>
              <button onClick={hide}>Close</button>
            </Modal>
          </>
        );
      }}
    />
  );
}

With UI Libraries

shadcn/ui

import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { HeadlessModal } from '@tour-kit/announcements/headless';

<HeadlessModal
  id="announcement"
  render={({ isVisible, config, hide }) => (
    <Dialog open={isVisible} onOpenChange={hide}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{config.title}</DialogTitle>
        </DialogHeader>
        <p>{config.description}</p>
      </DialogContent>
    </Dialog>
  )}
/>

Radix UI

import * as Dialog from '@radix-ui/react-dialog';
import { HeadlessModal } from '@tour-kit/announcements/headless';

<HeadlessModal
  id="announcement"
  render={({ isVisible, config, hide }) => (
    <Dialog.Root open={isVisible} onOpenChange={hide}>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>{config.title}</Dialog.Title>
          <Dialog.Description>{config.description}</Dialog.Description>
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )}
/>

Accessibility

Headless components provide accessibility primitives:

<HeadlessModal
  id="announcement"
  render={({ isVisible, config, hide, ...a11yProps }) => (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={a11yProps.titleId}
      aria-describedby={a11yProps.descriptionId}
    >
      <h2 id={a11yProps.titleId}>{config.title}</h2>
      <p id={a11yProps.descriptionId}>{config.description}</p>
    </div>
  )}
/>

Each headless component provides appropriate ARIA attributes. See individual component docs for details.


TypeScript

Full type safety with render props:

import type { HeadlessModalProps, HeadlessRenderProps } from '@tour-kit/announcements/headless';

const renderModal = ({ isVisible, config, hide }: HeadlessRenderProps) => {
  // Fully typed render function
};

<HeadlessModal id="announcement" render={renderModal} />

Next Steps

Explore each headless component for variant-specific render props:

On this page