caliper uidocs

Ticket stub

Free

A ticket with a perforated stub. Checking in tears the stub off along the perforation, so used is something you can see on the ticket and not only read. The tear is the feedback; the plain text line on the ticket is the record.

Installs components/ui/ticket-stub.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add @caliper/ticket-stub
Halden Rail · 18:42 to Porto
Gate BSeat 14C
Valid for entry · 1 passenger

Press Check in, or pull the stub down.

When to use #

Use it for

  • Check-in, redeem and validate flows that cannot be repeated by accident.
  • Showing that a booking, coupon or pass has been used.

Skip it when

  • Reversible toggles; use a switch.
  • Anything that must stay readable for years; the ticket is a status, not an archive.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Nothing depends on dragging
Check in is a button with a live status. The stub can also be pulled down with a mouse or finger, past 60px it tears.
Undo takes focus
After the tear the Check in button is replaced by Undo, and focus lands on it so the next key press is the way back.

Styling

The tear is the only animation
Under reduced motion the stub is gone at once and the ticket keeps its text line.

Installation #

terminal
pnpm dlx shadcn@latest add @caliper/ticket-stub

Adds its 7 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useState } from "react"
import { TicketStub } from "@/components/ui/ticket-stub"

export function TicketStubDemo() {
  const [used, setUsed] = useState(false);
  return (
    <div className="w-full max-w-md">
      <TicketStub
        title="Halden Rail · 18:42 to Porto"
        meta={["Gate B", "Seat 14C"]}
        code="14C"
        used={used}
        onUse={() => setUsed(true)}
        onUndo={() => setUsed(false)}
        usedNote={<><b>Checked in 14:02</b> · stub kept by the gate</>}
      >
        Valid for entry · 1 passenger
      </TicketStub>
    </div>
  );
}

API reference #

TicketStub

component
PropTypeDefaultDescription
title *string—The event or route. Long names wrap on the ticket.
metareadonly string[]—Small mono labels under the title, such as the gate and the seat.
codestring—The short code printed on the stub, such as the seat.
stubLabelstring"ADMIT ONE"
childrenReactNode—The status line while the ticket is valid.
usedNoteReactNode"Checked in"The status line once the stub is torn off, such as "Checked in 14:02".
usedboolean—Controlled when set.
defaultUsedbooleanfalse
onUse(() => void)—
onUndo(() => void)—
actionLabelstring"Check in"
undoLabelstring"Undo check-in"
hintstring"Press Check in, or pull the stub down."Spoken and shown under the buttons while valid and after the tear.
doneHintstring"Checked in. The stub is torn off."
statusDepthStatus"ready"
messagesDepthMessages—
onRetry(() => void)—
disabledboolean—
classNamestring—

Accessibility #

It exposes group semantics, with aria-label, aria-hidden and aria-live kept in step with state. Changes that matter are announced through a live region.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.