Document / EN
Buyer UX guidelines
Practical guidance for platforms that embed the SeatsChart seat map in a storefront. Each section lists what to do, what to avoid and the embed options involved; every option named here is part of the embed API (full reference: Integration guide). The examples use the script-tag loader; the React, React Native, Vue and Angular components take the same options as props.
The short version: give the map most of the screen, show prices the way the buyer will pay them, let the viewer explain holds, gaps and expiry in its own words, and hand off to checkout only when every hold call has settled.
1. Phone layout and map height
The map is the product on a phone. Buyers pan and zoom with their fingers, so a map squeezed between banners is the most common cause of abandoned selections.
Do
- Give the chart at least 70 % of the visible height on phones, using
svhunits so the browser bar does not resize it. - Keep the default
gestureHandling(auto): inside your page one finger scrolls the page and two fingers move the map, so the buyer is never trapped. - Keep the fullscreen button (
showFullScreenButton, defaulttrue); the loader fills your window with the map (fullScreenTarget: "host"). - Leave the bottom of the map free: the viewer draws its cart, sheets and checkout button there.
Don't
- Don't use
100vh, fixed pixel heights under 480 px, or a container that scrolls sideways. - Don't overlay sticky bars, cookie banners or chat bubbles on the map.
new SeatsChart({
divId: "seat-map", event: "EVENT_ID", token: "BUYER_JWT",
height: "min(78svh, 760px)", // any CSS length; default 640px
gestureHandling: "auto",
showMinimap: false, // phones: the minimap competes with the map for space
}).render();More: Buyer experience.
2. Legend, prices and fees
Buyers compare categories by colour and price before they look at seats. The inline legend shows a colour dot, the category and its price range; the overview shows a "from" price on each section.
Do
- Keep the legend on (
showLegend) and hide categories nobody can buy right now withlegend: { hideNonSelectableCategories: true }. - Format prices like the rest of your shop with
priceFormatterand the eventcurrency;showFreeAsTextshows "Free" instead of 0. - Show fees the way your market expects. Inclusive:
allFeesIncluded: trueshows "incl. €2.50 fee". Exclusive:allFeesIncluded: falseshows "+ €2.50 fee". The amount charged never changes; only the wording does.
Don't
- Don't hide prices (
legend.hidePricing,tooltip.hidePrice) unless the price really depends on the buyer. - Don't show one price on the map and another at checkout: send the same
pricingyour checkout uses.
pricing: {
allFeesIncluded: false, // exclusive: "+ €2.50 fee"; true: "incl. €2.50 fee"
prices: [
{ category: "Orchestra", price: 80, fee: 2.5 },
{ category: "Balcony", price: 45, fee: 1.5 },
],
},
priceFormatter: { maximumFractionDigits: 2, showFreeAsText: true },
legend: { hideNonSelectableCategories: true },More: Legend, filter controls, floors and form posts, partner pricing.
3. Ticket types
Ticket types (adult, child, half price) are chosen per seat. On a seat with several types the viewer opens a card next to the seat on desktop and a bottom sheet on phones.
Do
- Give every type a short name and a one-line
description("Students and over 60s, ID at the door"). - Mark the usual choice with
primary: true; mark sold-out typesunavailablewith anunavailableReasoninstead of removing them. - Pass
seatTicketTypesfrom the hold hand-off unchanged to your quote and confirmation.
Don't
- Don't ask for the ticket type again at checkout; the buyer has already chosen it and can change it in the cart.
- Don't draw your own picker (
onTicketTypePrompt) unless it is at least as fast as the built-in card.
pricing: { prices: [{ category: "Orchestra", ticketTypes: [
{ ticketType: "adult", label: "Adult", price: 80, primary: true },
{ ticketType: "half", label: "Half price", price: 40, description: "Students and over 60s, ID at the door" },
] }] },
objectPopover: { confirmTicketTypeSelection: false }, // a tap on a type selects at once4. Hold timer and expiry
Seats are held while the buyer completes the purchase. The viewer shows a countdown ("Complete payment within 4:59") and reads it out to screen readers at 5, 2 and 1 minute and at 30 seconds.
Do
- Show your own countdown from the
expiresAtof the latesthold.succeededorsession.initialized, never from the time the page opened: every new selection restarts the hold period. - Extend the hold from your server when the buyer reaches payment (
POST /api/hold-tokens/{holdToken}withexpiresInMinutes); holds never last longer than 2 hours. - When
onSelectedObjectUnavailablereportsreason: "expired", say so plainly and keep the buyer on the map so they can pick again.
Don't
- Don't silently empty the cart or reload the page when a hold expires.
- Don't show a timer before anything is held.
onSessionInitialized: ({ expiresAt }) => countdown.start(expiresAt),
onHoldSucceeded: ({ expiresAt }) => countdown.start(expiresAt),
onSelectedObjectUnavailable: ({ reason }) => { if (reason === "expired") showNotice("Your seats were released. Pick them again to continue."); },
messages: { "viewer.embedHoldTimer": "Finish within {time}" },More: Hold timer, Checkout flow.
5. Orphan seats
A single free seat left between taken seats is hard to sell. selectionValidators.noOrphanSeats is on by default: the viewer rings the stranded seat, explains why and waits with checkout until the selection is valid. It never refuses the click itself.
Do
- Keep the default and let the viewer's message do the explaining; adjust the wording with
messages(viewer.seatOrphanBlocked, placeholder{seats}). - Use
mode: "lenient"for small rows where a gap cannot always be avoided. - Listen to
onSelectionInvalid(reason: "orphan_seats") only to disable your own pay button.
Don't
- Don't turn the validator off to "reduce friction" without a box-office plan for single seats.
- Don't use
disallow: trueon phones unless buyers usually pick whole groups: refused taps feel broken.
selectionValidators: { noOrphanSeats: { mode: "lenient" } },
onSelectionInvalid: ({ reason }) => { if (reason === "orphan_seats") payButton.disabled = true; },
onSelectionValid: () => (payButton.disabled = false),6. Whole rows and List places on phones
Groups and buyers who struggle with small seats on a phone need a faster path than tapping seat by seat.
Do
- Keep multi-select on and leave
maxSelectedObjectsunset when groups book whole rows: the selection menu then offers Select whole rows. - Enable
stepThroughso phones (under 650 px) get List places, a sheet that walks floors, sections, rows and seats with large targets, and a guided "where and how many" flow. - Combine it with
showSearchButton: trueso buyers can find a seat by its label.
Don't
- Don't set
numberOfPlacesToSelector apartySizeprompt if you want whole rows; those modes select by quantity instead.
stepThrough: { enabled: true },
showSearchButton: true,
selectionModes: ["click", "rectangle", "lasso"],More: Guided selection, seat search and colours.
7. Multi-floor venues
Do
- Open on the floor the buyer came for with
activeFloor; without it the map opens on the all-floors view (2D), where buyers pick a floor first. Keep the elevator (showFloorElevator, defaulttrue) so other floors stay one tap away. - Use
lockActiveFloor: trueonly on pages that sell a single floor. - Choose the all-floors view that matches the venue:
multiFloor: { view: "stage" }(seen from the stage, the default) or"isometric"; buyers see it first when there is noactiveFloorand come back to it with All floors. - Keep your page in step with
onFloorChanged(breadcrumbs, filters).
Don't
- Don't build your own floor tabs above the map; they drift out of sync with the elevator.
activeFloor: "Balcony",
multiFloor: { view: "stage" },
onFloorChanged: ({ label, allFloors }) => breadcrumb.set(allFloors ? "All floors" : label),8. Accessibility
The viewer is keyboard and screen-reader ready: every seat is reachable through the accessible seat list, the controls have labels, and changes are announced in one live region.
Do
- Give the iframe a meaningful
title("Seat map, Main Hall"); the default is "Seat map". - Offer accessible seating up front:
accessibilityFilter: ["wheelchair"]on an accessible-seating link, and keep the accessibility group of the Filters panel (filterControls). - Offer the colour-blind-safe palette by default where it helps (
statusPalette: "accessible"); buyers can switch it in the toolbar. - Use
holdBestAvailable({ accessibleSeats: 1, number: 2 })for "wheelchair space plus companion" buttons.
Don't
- Don't put
aria-hidden,tabindex="-1"orinerton the map container, and don't trap focus around it. - Don't rely on colour alone in your own cart: repeat the category name.
9. Dark mode and host theming
Do
- Follow your site's theme:
theme: "auto"follows the buyer's system; if your site has its own switch, passtheme: "light"or"dark"and callchart.rerender({ theme })when it changes (the hold session is kept). - Match your brand with
colors(accent,selected,title) andstyle(cornerRadius,buttonShape,font). - Use
transparent: trueorbackgroundColorso the map sits on your page background.
Don't
- Don't recolour seat statuses for branding (
objectColor.byStatus); buyers learn what "taken" looks like.
theme: document.documentElement.dataset.theme === "dark" ? "dark" : "light",
colors: { accent: "#0f766e", selected: "#0f766e" },
style: { cornerRadius: 12, buttonShape: "round", font: "system" },
transparent: true,10. Performance
Do
- Start loading early:
<link rel="preconnect">to the viewer origin, and fetch the buyer token in parallel with the loader script. - Pin a hashed loader (
/embed/v1/seatschart.<hash>.jswithintegrity) for an immutable, cached script. seats.io'schartJsUrlhas no SeatsChart counterpart: the scriptsrc(orwebBasewith the npm package) decides which viewer loads. - Pass
publicKey: map reads then come from the CDN. - Render the chart once and patch it with
changeConfig; keeprerender()for changes that need a new iframe. - Below the fold, create the chart when its container comes near the viewport.
- Watch
onChartRendered(metrics.timeToFirstSeatPaintMs) in your own analytics.
Don't
- Don't destroy and re-create the chart on every framework render or route change.
<link rel="preconnect" href="https://seatschart.com" crossorigin>
<script>
new IntersectionObserver(([entry], observer) => {
if (!entry.isIntersecting) return;
observer.disconnect();
new SeatsChart({ divId: "seat-map", event: "EVENT_ID", token: "BUYER_JWT", publicKey: "pub_…" }).render();
}, { rootMargin: "400px" }).observe(document.getElementById("seat-map"));
</script>11. Checkout handoff
Do
- Go to payment from
onHoldCreated(the viewer's checkout button) or, with your own button, afteronHoldCallsComplete(readchart.holdToken). - Disable your pay button while
onHoldCallsInProgressis pending and while the selection is invalid. - Quote, charge and confirm on your server with your API key; the viewer shows Purchase confirmed once the confirmation lands.
- Plain HTML forms: use
holdTokenInputNameandselectedObjectsInputNameinstead of copying values by hand.
Don't
- Don't confirm from the browser or send the buyer token to your payment provider.
- Don't release the hold when the buyer goes back from payment; they expect their seats to still be there.
onHoldCallsInProgress: () => (payButton.disabled = true),
onHoldCallsComplete: () => (payButton.disabled = false),
onHoldCreated: (hold) => goToPayment(hold), // eventId, seatIds, userId, holdToken, seatTicketTypesMore: Quick start, Server integration.
Checklist
- The map takes most of the phone screen and nothing covers its bottom edge.
- Prices and fees on the map match checkout.
- Ticket types have short names and descriptions.
- Your countdown follows the latest
expiresAt; expiry is explained, not silent. - Orphan-seat checks are on (or replaced by a box-office plan).
- Phones get List places (
stepThrough) and whole rows where groups book. - Multi-floor venues open on the right floor.
- Keyboard, screen-reader and accessible-seating paths tested.
- Theme follows your site, light and dark.
- The chart is created once, and checkout starts after hold calls settle.
