This feature is currently in beta. Documentation for Version 1 templates is here
Template Markup & Structure — How to build and customise the HTML template for your waiting room.
See also: Vue State & Methods | Styling & CSS Variables
Getting Started
Open the template HTML file in both a text editor and a browser. The template contains a <script id="crowdhandler-data" type="application/json"> tag with a JSON object that configures the waiting room for testing.
<script id="crowdhandler-data" type="application/json">
{
"dev": true,
"status": 1,
"title": "Test Event",
"position": 150,
"estimate": 12,
"simulatePolling": true
}
</script>With "dev": true set, the template uses the values in this JSON object instead of making real API calls. Change the properties, save the file, and refresh the browser to see different states.
Preview Status Values
| Status | State |
|---|---|
1 | Active Queue |
3 | Blocked |
4 | Countdown |
5 | Room Full |
For the full list of preview properties, see the Preview Mode reference.
Preview Examples
// Active queue
{"dev": true, "status": 1, "position": 100, "estimate": 10}
// Countdown
{"dev": true, "status": 4, "onsale": "2026-02-15T14:00:00Z"}
// Blocked
{"dev": true, "status": 3}Overview
The template uses Vue 3 directives to conditionally render sections based on the queue state. The main state object ch contains all queue data and is automatically updated via API polling (default every 60 seconds, variable TTL).
Required Elements
<!-- Container that shows when loaded -->
<div id="crowdhandler-template" :class="{ 'active': ch.isLoaded }" role="main">
<!-- Screen reader announcements -->
<div class="ch-sr-only" aria-live="polite" aria-atomic="true">{{ ch.announcement }}</div>
<!-- Your template content -->
<!-- Captcha container (required if captcha is enabled) -->
<div id="ch-captcha-form"></div>
<!-- Script inclusion -->
<script type="module" src="/src/main.js"></script>
</div>Queue States
The default template uses ch.captchaRequired as the top-level gate, then checks other states within the non-captcha branch using a v-if / v-else-if chain.
1. Captcha Required
ch.captchaRequired
User must complete captcha before entering queue. The captcha widget renders automatically in #ch-captcha-form.
<div v-if="ch.captchaRequired" class="ch-captcha-section"> <p class="ch-captcha-message">Please verify you're human to continue</p> <div id="ch-captcha-form"></div> </div> <div v-else> <!-- All other states go here --> </div>
To test captcha add the required captcha properties in the
<script id="crowdhandler-data" type="application/json"> tag. This will display the captcha block but will not allow live testing of captcha due to browser security.
<script id="crowdhandler-data" type="application/json">
{
"dev": true,
"status": 1,
"title": "Test Event",
"captchaRequired":true,
"captchaType":"recaptcha",
"captchaKeyPublic":"1234567890"
}
</script>2. Blocked
ch.isBlocked
User has been blocked due to suspicious activity.
<div v-if="ch.isBlocked" class="ch-state-message ch-state-danger" role="alert"> <h3 class="ch-state-title">Access Denied</h3> <p class="ch-state-description">You've been blocked due to suspicious activity.</p> </div>
3. Room Full
ch.isRoomFull
Queue is at maximum capacity. Must use v-else-if after blocked check.
<div v-else-if="ch.isRoomFull" class="ch-state-message ch-state-warning" role="alert"> <h3 class="ch-state-title">Queue Full</h3> <p class="ch-state-description">We're at capacity. You'll be let in when a spot opens up.</p> </div>
4. Countdown (Pre-Queue)
ch.isCountdown
The queue hasn't opened yet. Show a countdown. sHours, sMinutes and sSeconds are zero-padded strings.
<div v-if="ch.isCountdown" class="ch-countdown">
<p class="ch-countdown-label">Queue opens in</p>
<div class="ch-countdown-display">
<div class="ch-countdown-unit">
<div class="ch-countdown-value">{{ ch.countdown.sHours }}</div>
<div class="ch-countdown-suffix">hrs</div>
</div>
<div class="ch-countdown-unit">
<div class="ch-countdown-value">{{ ch.countdown.sMinutes }}</div>
<div class="ch-countdown-suffix">min</div>
</div>
<div class="ch-countdown-unit">
<div class="ch-countdown-value">{{ ch.countdown.sSeconds }}</div>
<div class="ch-countdown-suffix">sec</div>
</div>
</div>
</div>5. Active Queue
ch.isActive
User is in the active queue with a position. When the user reaches the front, they are automatically redirected.
<div v-if="ch.isActive">
<h2 v-if="ch.position">{{ ch.position }}</h2>
<div v-else>Checking in!</div>
</div>Template Sections
Header (Logo + Title)
Title gets a dynamic size class based on character count.
<header class="ch-header">
<div class="ch-logo ch-enter ch-stagger-1" v-if="ch.logo">
<img :src="ch.logo" :alt="ch.title" />
</div>
<div class="ch-header-row ch-enter ch-stagger-4">
<h1 class="ch-title" :class="ch.css.title">{{ ch.title }}</h1>
</div>
</header>Message
Displayed above the card. Supports newlines via white-space: pre-line. Announced to screen readers when it changes.
<div class="ch-message ch-message-box ch-enter ch-stagger-6" v-if="ch.message">
{{ ch.message }}
</div>Countdown + Active Queue Wrapper
These states share a wrapper since the queue transitions from countdown to active.
<div v-if="ch.isCountdown || ch.isActive" class="ch-queue-states ch-enter ch-stagger-4"> <!-- Countdown, position, progress bar, priority code, stock --> </div>
Position Display
<div class="ch-position-display" v-if="ch.position && ch.isActive">
<div class="ch-position-label">Your position</div>
<h2 class="ch-position-number" :key="ch.position"><strong>{{ ch.position }}</strong></h2>
</div>Progress Bar
Shows queue progress when a position exists. Shows a poll-based progress bar when checking in (ch.position is null).
<!-- With position -->
<div v-if="ch.position && ch.isActive" class="ch-progress-container">
<div class="ch-progress-bar" role="progressbar"
:aria-valuenow="Math.round(ch.progress || 0)"
aria-valuemin="0" aria-valuemax="100">
<div class="ch-progress-fill"
:style="{ width: (isNaN(ch.progress) ? 10 : ch.progress) + '%' }"></div>
</div>
<div class="ch-progress-info">
<span v-if="ch.estimate && ch.estimate <= 1">Almost there!</span>
<span v-else-if="ch.estimate && ch.estimate > 60">More than an hour</span>
<span v-else-if="ch.estimate">Approximately {{ ch.estimate }} minutes</span>
<span v-if="ch.eta" class="ch-eta">ETA: {{ ch.eta }}</span>
</div>
</div>
<!-- Checking in (no position yet) -->
<div v-else-if="ch.isActive" class="ch-progress-container">
<div class="ch-position-label ch-checking-in">Checking in!</div>
<div class="ch-progress-bar" role="progressbar"
:aria-valuenow="Math.round(ch.nextPoll || 0)"
aria-valuemin="0" :aria-valuemax="ch.pollTTL">
<div class="ch-progress-fill"
:style="{ width: (isNaN(ch.nextPoll) ? 0 : (ch.nextPoll / ch.pollTTL * 100)) + '%' }">
</div>
</div>
</div>Stock Indicator
<div v-if="ch.stock !== null" class="ch-stock-badge"
:class="{
'ch-stock-low': ch.stock > 0 && ch.stock <= 50,
'ch-stock-zero': ch.stock === 0
}">
<span v-if="ch.stock > 0">
<span class="ch-stock-count">{{ ch.stock }}</span> items remaining
</span>
<span v-else>Sold out</span>
</div>Session Info
<!-- During countdown -->
<div v-if="ch.isCountdown" class="ch-session-info">
Stay on this page. You'll get a random position when the queue opens.
</div>
<!-- During active queue -->
<div v-if="ch.isActive">
<span v-if="ch.sessionsExpire" class="ch-session-info">
Keep this window open. You'll move forward automatically.
</span>
<span v-else-if="ch.sessionsTimeout" class="ch-session-info">
You'll be redirected when it's your turn.
You have {{ ch.sessionsTimeout }} minutes to complete your transaction.
</span>
</div>Email Notification Form
Three states: input form, submitting spinner, and confirmation. Users can change their email after submission. Only shown during active queue.
<div v-if="ch.emailAvailable && ch.isActive" class="ch-form-section ch-email-section">
<!-- Submitting -->
<div v-if="ch.email.submitting" class="ch-email-submitting">
<div class="ch-spinner" role="status" aria-label="Submitting"></div>
</div>
<!-- Form -->
<div v-else-if="!ch.email.submitted || ch.email.changing">
<div v-if="ch.email.changing" class="ch-form-title">Change notification email</div>
<div v-else class="ch-form-title">Get notified when it's your turn</div>
<div class="ch-input-group">
<input type="email" class="ch-input" placeholder="your@email.com"
v-model="ch.email.value" @keyup="validateEmail" />
<button class="ch-btn" @click="submitEmail" :disabled="!ch.email.valid">Notify me</button>
<button v-if="ch.email.changing" class="ch-btn ch-btn-secondary"
@click="cancelChangeEmail">Cancel</button>
</div>
</div>
<!-- Confirmation -->
<div v-else role="status">
<div class="ch-form-feedback ch-success">We'll email {{ ch.email.value }}</div>
<button @click="changeEmail" class="ch-btn ch-btn-sm ch-btn-change">Change</button>
</div>
</div>Priority Code Form
Available during both countdown and active queue states.
<div v-if="ch.priority && ch.priority.available" class="ch-form-section ch-priority-section">
<div class="ch-form-title">Got a priority code?</div>
<div class="ch-input-group">
<input type="text" class="ch-input" id="ch-priority-input"
placeholder="Enter code" v-model="ch.priority.code"
@keyup="checkValidPriorityCode" />
<button class="ch-btn" @click="prioritySubmit"
:disabled="ch.priority.submitting || !ch.priority.code">Apply</button>
</div>
<div v-if="ch.priority.submitted && ch.priority.success"
class="ch-form-feedback ch-success" role="status">Code applied!</div>
<div v-if="ch.priority.submitted && ch.priority.error"
class="ch-form-feedback ch-error" role="status">Invalid code</div>
</div>Footer
Three-column layout: last check-in time (left), session token (center), powered-by (right).
<div class="ch-footer ch-enter ch-stagger-5" v-if="!ch.captchaRequired">
<div class="ch-footer-left">
<div class="ch-footer-meta" v-if="ch.requested">
<span>Last check in:</span>
<code class="ch-footer-meta-value">
<span :class="{ 'ch-text-shimmer': ch.nextPoll < 3 }">
{{ formatDate(ch.requested, 'toLocaleTimeString') }}
</span>
</code>
</div>
</div>
<div class="ch-footer-center">
<div class="ch-token-display" v-if="ch.token.obfuscated || ch.token.value">
<span>ID:</span>
<code class="ch-token-value" tabindex="0" role="button"
@click="(e) => ch.token.copy(e, ch)">
{{ ch.token.copied ? 'Copied!' : (ch.token.obfuscated || ch.token.value) }}
</code>
</div>
</div>
<div class="ch-powered-by">
Powered by <a href="https://www.crowdhandler.com/..." target="_blank">CrowdHandler</a>
</div>
</div>Customisation Philosophy
The index.html template is a reference implementation — it covers every feature the system supports.
Keep the features, change the design
Every section is optional, except the #crowdhandler-template container and the script include, but we strongly recommend keeping all of the functionality in your template. The application won't break if you remove a feature's markup — it simply won't render UI for it. However, it's much easier to have the markup already in place when you need to enable a feature during a live on-sale. For example, if you decide to turn on captcha or stock display mid-event, the template renders it with no further changes. Without the markup, you'd need to update and re-deploy the template — and template caching means that update won't take effect straight away.
Reshape the state logic
The Vue directives are yours to rearrange. You could check ch.stock === 0 first and show a full-screen "Sold Out" panel, combine countdown with a teaser message, or embed the priority code form inside the countdown. The reference template groups states in a particular hierarchy, but you're free to restructure.
Build around the data, not the markup
Think of ch as a data API rather than a prescribed layout. The state properties are reactive values you can use anywhere, in any combination, with whatever conditional logic makes sense for your design. The reference template is a starting point, not a constraint.