RoadieRoadie
  • Home
  • Brand
  • Foundations
  • Content
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeBrandFoundations
Appearance

Widgets

  • CartContents
  • CartDrawerBeta

CartDrawer

Status
Beta
Added
Widgets 0.1.027 May 2026
Updated
Widgets 3.7.020 Oct 2026
Docs updated
9 Oct 2026

A docked, expandable cart for collection and event pages. It has matching React and Vue skins over one shared core.

  • Fetches its own summary and details, then groups items by event
  • Locale-aware currency and dates, with a live urgency countdown
  • Self-contained focus trap, body scroll lock, and motion

Live demo

The drawer is position: fixed over the whole page, so only the controls sit in the example below. Use the React / Vue tabs to compare the two skins driven by the same controls and the same mock cart. Show cart docks the summary bar. Expand cart opens the full cart. It runs on a canned cart (no network), so emptying and reopening starts fresh.

Cart context

import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
// Wrap once in a @tanstack/react-query QueryClientProvider, then:
<CartDrawer cart={cart} collectionId={id} onNavigate={go}
locale="en-AU" currency="AUD" />

In production you render CartDrawer once on the page, with no buttons. Users tap or drag the docked bar to expand it. createDemoCart is a docs-only stub; real apps pass a cart client.

Installation

pnpm add @oztix/roadie-widgets

Install the peer dependencies for the skin you use.

React

pnpm add react react-dom @oztix/roadie-components @tanstack/react-query motion \
@number-flow/react @phosphor-icons/react react-focus-lock

Vue

pnpm add vue motion @number-flow/vue @phosphor-icons/vue focus-trap

Tailwind setup

The drawer's classes live in the package's compiled output, so Tailwind needs to scan it. Otherwise the styles never make it into your CSS. Add the CSS imports for the skin you use to your global stylesheet.

React. The React skin renders @oztix/roadie-components, so include its CSS as well:

/* app/globals.css */
@import '@oztix/roadie-core/css';
@import '@oztix/roadie-components/css';
@import '@oztix/roadie-widgets/css';

Vue. The Vue skin uses core classes only, so it needs no components CSS:

/* app/globals.css */
@import '@oztix/roadie-core/css';
@import '@oztix/roadie-widgets/css';

Each package's CSS ships its own @source, so Tailwind scans the compiled classes without you ever writing a node_modules path.

Import

// React
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
// Vue
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/vue'

The framework-agnostic core is imported separately:

import { createCartClient } from '@oztix/roadie-widgets/cart'

The cart client

The drawer never talks to the network directly. You inject a CartClient. Use createCartClient to build one from a host plus an optional fetch implementation. It owns the cart endpoints and sends credentials: 'include'.

import { createCartClient } from '@oztix/roadie-widgets/cart'
const cart = createCartClient({
host: '', // empty string for same-origin
// fetch, // optional, defaults to globalThis.fetch
})

A CartClient exposes four methods, so you can supply your own if you need a custom transport:

interface CartClient {
getSummary(collectionId: string): Promise<CartSummary | null>
getDetails(collectionId: string): Promise<CartDetails | null>
checkoutUrl(details: Pick<CartDetails, 'extrasUrl'>): string | null
removeItem(cartId: string, eventId: string): Promise<void>
}

The drawer checks two fields from the cart API. extrasUrl must be a same-origin path with one leading slash, or checkoutUrl returns null and checkout is unavailable. imageUrl must be an absolute http or https URL, or the event image is dropped.

Event schedules

Each CartEvent formats its own time row from structured fields. Supply UTC timestamps and venue-local day keys. The shared core renders the rest the same way in every skin and consumer.

Fields suppliedTime row
eventStartAtUtc7pm
+ eventEndAtUtc (same day)7pm to 11pm
+ eventEndDateKey (different day)6:30pm to Sun 4 Oct, 9pm

Pass eventDateDisplay to override the computed schedule with a pre-formatted string. It's an escape hatch, so prefer the structured fields.

Events are ordered by eventStartAtUtc and grouped into days by eventDateKey (YYYY-MM-DD at the venue). Times show in eventTimeZone, which must be an IANA id such as Australia/Brisbane. Without it, times show in the viewer's time zone, so supply it whenever venue time matters.

Reserved seating

Ticket lines carry their seats as structured data. The drawer groups them by section and row and collapses consecutive seats into a range via the shared formatSeatRange helper. React, Vue, and every consumer render seating identically, with no app-side formatting to drift.

interface CartSeat {
section?: string // "Stalls", "Mezzanine", "Booth"
row?: string // "B"
seat: string // "12"
}
SeatsBadge
Stalls B11, B12Stalls B11–12
Stalls B11, B12, B14Stalls B11–12, 14
Booth 4 (no row)Booth 4
Mezzanine M3 + Circle C5Mezzanine M3 · Circle C5

Omit seats for general-admission tickets. The seat badge only renders when seats are present. Add an event in the live demo to see the reserved-seating examples (Tuxedo Possum Cabaret, Velvet Dingo Fest).

Usage

React

The React skin uses @tanstack/react-query internally, so mount it under a QueryClientProvider. Routing is yours to own, so onNavigate is required.

'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { createCartClient } from '@oztix/roadie-widgets/cart'

Vue

<script setup lang="ts">
import { createCartClient } from '@oztix/roadie-widgets/cart'
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/vue'
const cart = createCartClient({ host: '' })

The Vue skin also exports useRoadieTheme to mirror the host app's light/dark mode onto the drawer (which renders to <body>).

Controlling open/close

By default the drawer is uncontrolled. Users tap or drag the docked bar, and it owns its state (seed it with initialState). To open or close it from elsewhere in your UI (e.g. an inline "View cart" button), control it with open + onOpenChange: changing open animates the drawer with the same spring as a tap, and tap/drag report back through onOpenChange so you keep them in sync.

// React
const [open, setOpen] = useState(false)
;<button onClick={() => setOpen(true)}>View cart</button>
;<CartDrawer open={open} onOpenChange={setOpen} cart={cart} … />
<!-- Vue: v-model:open -->
<button @click="open = true">View cart</button>
<CartDrawer v-model:open="open" :cart="cart" … />

Page layout

The docked drawer writes its height to --cart-drawer-height on <html>, and onHeightChange reports the same height in pixels. The drawer also floats above the bottom edge (bottom-3, or bottom-4 from sm), so pad the bottom of your page by both or the last of your content sits under the drawer.

main {
padding-bottom: calc(var(--cart-drawer-height, 0px) + 0.75rem);
}
@media (width >= 40rem) {
main {

Mount context

context decides what the open-state secondary button ("Browse events") does:

  • 'collection' (default) closes the drawer. The collection page is already behind it.
  • 'event' navigates to the collection page to browse more events.

The browse target is always built inside the package from the server-trusted collectionId and routed through onNavigate. Consumers never supply the browse URL, which removes the open-redirect surface. Pass browseHref only to override the default collection-cart route; unsafe values fall back to the safe default.

Forcing a refetch

The drawer caches its summary and details. Bump refreshKey after a mutation elsewhere in the app (for example, adding tickets on the event page) to force a fresh fetch.

Expiry

The drawer itself only runs the urgency countdown and fires onExpire when the hold reaches zero. It does not render any expiry UI. The full "hold expiring / hold ended" experience is opt-in, shipped as two exports per skin that you compose yourself:

  • useCartExpiry(expiresAtUtc) is a headless hook (React) / composable (Vue), with no UI of its own. It runs the same one-second countdown as the badge and returns { remaining, expired, showWarning, dismissWarning }. showWarning turns on inside the final urgency band (under two minutes) and is a one-shot per expiresAtUtc: dismissing hides it until the server extends the hold, which re-arms it. expired becomes true once the hold reaches zero.
  • CartExpiryDialogs is the presentational layer, built on the Dialog compound. Given that state it renders two alertdialogs. The first is a dismissible "Still here?" warning (warning intent) with a live mm:ss countdown and Keep browsing / Checkout actions. Checkout stays disabled until checkoutUrl is ready. The second is a blocking "Your hold has ended" dialog (danger intent, no light-dismiss) whose only exit is Browse events.

Trigger each dialog to see it. Switch the React / Vue tabs to compare skins. The warning is light-dismissible (close button, backdrop, or Escape); the expired dialog is blocking, so Browse events is the only way out.

import { CartExpiryDialogs, useCartExpiry } from '@oztix/roadie-widgets/cart-drawer/react'
const { remaining, expired, showWarning, dismissWarning } = useCartExpiry(expiresAtUtc)
<CartExpiryDialogs showWarning={showWarning} expired={expired} remaining={remaining}
onDismissWarning={dismissWarning} checkoutUrl={url} browseHref="/events" onNavigate={go} />

Wire them together once, alongside the drawer. In production you derive the props from useCartExpiry (the Vue composable takes a getter so it tracks prop changes):

import {
CartExpiryDialogs,
useCartExpiry
} from '@oztix/roadie-widgets/cart-drawer/react'
function CartExpiryLayer({ expiresAtUtc, checkoutUrl, browseHref, onNavigate }) {

The Vue skin exposes the same CartExpiryDialogs component and useCartExpiry composable from @oztix/roadie-widgets/cart-drawer/vue, with identical props. CartExpiryModals remains as a deprecated alias and will be removed in v3.0.0. CartExpiryDialogs' props are documented in the API reference below.

Accessibility

  • Focus is trapped while the drawer is open and returns to the trigger on close. React uses react-focus-lock and Vue uses focus-trap.
  • Body scroll is locked by default while open (lockBodyScroll).
  • Escape closes the drawer.
  • Motion respects prefers-reduced-motion: the open/close and drag animations are reduced when the user has requested it.

The Vue skin accepts the same props as the React skin documented below.

API reference

CartDrawer

cartCartClient
Required

Core cart client (host + fetch injected by the consuming app).

collectionIdstring
Required
onNavigate(href: string) => void
Required

REQUIRED — routing is the consumer's job.

browseHrefstring

Empty-state "Browse" target. Defaults to a safe collectionId-derived path; unsafe values fall back to that default.

context"collection" | "event"

Mount context for the open-state secondary button. Browse target is package-built from collectionId — never a consumer URL — to avoid open-redirect.

Defaults to collection.

localestring
Required

Locale for currency/date formatting.

currencystring
Required

ISO 4217 currency code.

refreshKeynumber

Bump to force a refetch of summary + details.

lockBodyScrollboolean

Lock body scroll while open. Default true.

Defaults to true.

openboolean

Controlled open state. When provided, the drawer animates to match it — drive it from your own state so any UI (e.g. a "View cart" button) can open/close it, echoing `onOpenChange` back into it. Omit for uncontrolled (tap/drag) behaviour seeded by `initialState`.

initialState"closed" | "open"

Uncontrolled initial state when `open` is omitted. Default 'closed'.

Defaults to closed.

onOpenChange((open: boolean) => void)

Fires on every open/close intent (tap, drag, Escape, backdrop, or `open`).

onExpire(() => void)

Fires when the urgency countdown hits expiry.

onHeightChange((px: number) => void)

Reports the docked (closed) drawer height in px.

CartExpiryDialogs

The hold's expiry dialogs, given `useCartExpiry`'s state: a dismissible "Still here?" warning with a countdown, then a blocking "Your hold has ended" dialog whose only exit is browsing events.

showWarningboolean
Required
expiredboolean
Required
remainingnumber | null
Required
onDismissWarning() => void
Required
checkoutUrlstring | null
Required
browseHrefstring
Required
onNavigate(href: string) => void
Required
Previous page← CartContents