Skip to main content

Headless Mode

For complete design freedom, use Fanfare React hooks to build your own UI components from scratch. This approach gives you full control over markup, styling, and behavior.

When to Use Headless

Use CaseRecommended Approach
Quick integrationWeb Components
Minor styling changesCSS Variables
Partial UI customizationSlots/Render Props
Complete design freedomHeadless Hooks
Brand-specific design systemHeadless Hooks
Complex animationsHeadless Hooks

Available Hooks

HookPurpose
useQueueVirtual waiting room
useDrawLottery/raffle
useAuctionReal-time bidding
useWaitlistPre-registration signup
useTimedReleaseFlash sale / time-window access
useExperienceJourneyFull journey orchestration
useFanfareAuthAuthentication management

Complete Headless Queue Example

import { useQueue, useFanfareAuth } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

function HeadlessQueue({ queueId }: { queueId: string }) {
  const { isAuthenticated, guest } = useFanfareAuth();
  const { queue, status, position, estimatedWait, admittanceToken, enter, leave, isLoading, error } = useQueue(queueId);

  const handleEnter = async () => {
    if (!isAuthenticated) {
      await guest();
    }
    await enter();
  };

  // Loading state
  if (isLoading) {
    return (
      <div className="queue-loading">
        <div className="spinner" />
        <p>Loading queue...</p>
      </div>
    );
  }

  // Error state
  if (error) {
    return (
      <div className="queue-error">
        <h3>Something went wrong</h3>
        <p>{error.message}</p>
        <button onClick={() => window.location.reload()}>Try Again</button>
      </div>
    );
  }

  // Admitted state
  if (status === "admitted" && admittanceToken) {
    return (
      <div className="queue-admitted">
        <div className="success-icon">&#10003;</div>
        <h2>You are In!</h2>
        <p>You have been admitted to the experience.</p>
        <a href={`/checkout?token=${admittanceToken}`} className="checkout-button">
          Continue to Checkout
        </a>
      </div>
    );
  }

  // Queued state
  if (status === "queued") {
    return (
      <div className="queue-waiting">
        <div className="position-display">
          <span className="position-number">{position}</span>
          <span className="position-label">in line</span>
        </div>

        {estimatedWait && <p className="wait-estimate">Estimated wait: ~{estimatedWait} minutes</p>}

        <div className="queue-info">
          <p>Please keep this page open.</p>
          <p>We will notify you when it is your turn.</p>
        </div>

        <button onClick={leave} className="leave-button">
          Leave Queue
        </button>
      </div>
    );
  }

  // Default: Entry state
  return (
    <div className="queue-entry">
      <h2>Join the Queue</h2>
      <p>Enter our virtual waiting room to access this exclusive experience.</p>

      <button onClick={handleEnter} className="enter-button">
        Enter Queue
      </button>

      <div className="queue-benefits">
        <h4>While you wait:</h4>
        <ul>
          <li>Your spot is saved</li>
          <li>Real-time position updates</li>
          <li>Automatic admission when ready</li>
        </ul>
      </div>
    </div>
  );
}

Complete Headless Experience Journey

import { useExperienceJourney, useFanfareAuth, useTranslations } from "@waitify-io/fanfare-sdk-react";
import { useState } from "react";

function HeadlessExperience({ experienceId }: { experienceId: string }) {
  const { isAuthenticated, isGuest, guest } = useFanfareAuth();
  const t = useTranslations();
  const { journey, state, status, error, start } = useExperienceJourney(experienceId);
  const [accessCode, setAccessCode] = useState("");

  const handleStart = async (code?: string) => {
    if (!isAuthenticated && !isGuest) {
      await guest();
    }
    await start({ accessCode: code });
  };

  // Render based on status
  switch (status) {
    case "idle":
      return <StartScreen onStart={() => handleStart()} />;

    case "entering_experience":
    case "routing_sequence":
    case "loading_distributions":
      return <LoadingScreen message="Setting up your experience..." />;

    case "needs_authentication":
      return <AuthScreen onComplete={() => journey?.authenticate()} />;

    case "needs_access_code":
      return (
        <AccessCodeScreen
          value={accessCode}
          onChange={setAccessCode}
          onSubmit={() => handleStart(accessCode)}
          onSkip={() => journey?.skipAccessCode()}
        />
      );

    case "waiting":
      return <WaitingScreen snapshot={state?.snapshot} onJoinWaitlist={() => journey?.enterWaitlist()} />;

    case "ready":
      return <ReadyScreen journey={journey!} state={state!} />;

    case "no_sequence_available":
      return <NoAccessScreen />;

    case "error":
      return <ErrorScreen error={error} onRetry={() => start()} />;

    default:
      return <LoadingScreen />;
  }
}

// Component implementations
function StartScreen({ onStart }: { onStart: () => void }) {
  return (
    <div className="start-screen">
      <h1>Welcome</h1>
      <p>Ready to begin your experience?</p>
      <button onClick={onStart} className="start-button">
        Get Started
      </button>
    </div>
  );
}

function LoadingScreen({ message = "Loading..." }: { message?: string }) {
  return (
    <div className="loading-screen">
      <div className="loader" />
      <p>{message}</p>
    </div>
  );
}

function AuthScreen({ onComplete }: { onComplete: () => void }) {
  const [email, setEmail] = useState("");

  return (
    <div className="auth-screen">
      <h2>Sign In</h2>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          onComplete();
        }}
      >
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email" />
        <button type="submit">Continue</button>
      </form>
    </div>
  );
}

function AccessCodeScreen({
  value,
  onChange,
  onSubmit,
  onSkip,
}: {
  value: string;
  onChange: (v: string) => void;
  onSubmit: () => void;
  onSkip?: () => void;
}) {
  return (
    <div className="access-code-screen">
      <h2>VIP Access</h2>
      <p>Enter your access code for priority entry</p>
      <input type="text" value={value} onChange={(e) => onChange(e.target.value)} placeholder="Access code" />
      <button onClick={onSubmit}>Submit</button>
      {onSkip && (
        <button onClick={onSkip} className="secondary">
          Skip
        </button>
      )}
    </div>
  );
}

function WaitingScreen({ snapshot, onJoinWaitlist }: { snapshot: JourneySnapshot | null; onJoinWaitlist: () => void }) {
  const isOnWaitlist = snapshot?.sequenceStage === "waitlist_entered";

  return (
    <div className="waiting-screen">
      {isOnWaitlist ? (
        <>
          <h2>You are on the Waitlist</h2>
          <p>We will notify you when the experience opens.</p>
        </>
      ) : (
        <>
          <h2>Coming Soon</h2>
          <p>Join the waitlist to be notified when we open.</p>
          <button onClick={onJoinWaitlist}>Join Waitlist</button>
        </>
      )}
    </div>
  );
}

function ReadyScreen({ journey, state }: { journey: ExperienceJourney; state: ExperienceJourneyState }) {
  const { snapshot } = state;
  const distributionType = snapshot.context.distribution?.active?.type;

  const handleEnter = () => {
    const actionMap: Record<string, string> = {
      queue: "enter_queue",
      draw: "enter_draw",
      auction: "enter_auction",
      timed_release: "enter_timed_release",
    };
    journey.perform(actionMap[distributionType || "queue"]);
  };

  if (snapshot.sequenceStage === "admitted") {
    return (
      <div className="admitted-screen">
        <h2>You are In!</h2>
        <a href={`/checkout?token=${snapshot.context.admittanceToken}`}>Continue to Checkout</a>
      </div>
    );
  }

  if (snapshot.sequenceStage === "participating") {
    return (
      <div className="participating-screen">
        <h2>You are participating</h2>
        <p>Please wait for your turn...</p>
        <button onClick={() => journey.perform("leave_participation")}>Leave</button>
      </div>
    );
  }

  return (
    <div className="ready-screen">
      <h2>Ready to Enter</h2>
      <p>The experience is now open!</p>
      <button onClick={handleEnter}>Enter Now</button>
    </div>
  );
}

function ErrorScreen({ error, onRetry }: { error: string | null; onRetry: () => void }) {
  return (
    <div className="error-screen">
      <h2>Something went wrong</h2>
      <p>{error || "An unexpected error occurred"}</p>
      <button onClick={onRetry}>Try Again</button>
    </div>
  );
}

function NoAccessScreen() {
  return (
    <div className="no-access-screen">
      <h2>No Access Available</h2>
      <p>This experience is not currently available to you.</p>
    </div>
  );
}

Styling Headless Components

Since you control all markup, use any styling approach:

CSS Modules

import styles from "./Queue.module.css";

function HeadlessQueue() {
  const { status, position } = useQueue("queue_123");

  return (
    <div className={styles.queue}>
      <span className={styles.position}>{position}</span>
    </div>
  );
}

Tailwind CSS

function HeadlessQueue() {
  const { status, position, enter } = useQueue("queue_123");

  return (
    <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-6 shadow-lg">
      <span className="text-4xl font-bold text-blue-600">{position}</span>
      <button onClick={enter} className="rounded-lg bg-blue-600 px-6 py-3 text-white transition hover:bg-blue-700">
        Enter Queue
      </button>
    </div>
  );
}

Styled Components

import styled from "styled-components";

const QueueContainer = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 2rem;
`;

const Position = styled.span`
  font-size: 3rem;
  font-weight: bold;
  color: ${(props) => props.theme.primary};
`;

function HeadlessQueue() {
  const { position } = useQueue("queue_123");

  return (
    <QueueContainer>
      <Position>{position}</Position>
    </QueueContainer>
  );
}

Best Practices

  1. Handle all states - Loading, error, and all distribution states
  2. Provide feedback - Show loading indicators for async actions
  3. Accessibility - Ensure proper ARIA labels and keyboard navigation
  4. Responsive design - Test across screen sizes
  5. Error boundaries - Wrap in React error boundaries