AniUI

Event Ticket

Event ticket card with perforated edge, date, venue, and seat.

Event Ticket

AniUI Live

Sep 6, 2026 · 7:00 PM

Mumbai, IN

Seat A-12

AX7K2
Web preview — components render natively on iOS & Android
import { EventTicket } from "@/components/ui/event-ticket";
<EventTicket title="AniUI Live" date="Sep 6" venue="Mumbai" seat="A12" />

Installation#

npx @aniui/cli add event-ticket

Usage#

import { EventTicket } from "@/components/ui/event-ticket";
<EventTicket title="AniUI Live" date="Sep 6" venue="Mumbai" seat="A12" />

Source#

components/ui/event-ticket.tsx
import React, { useState } from "react";
import { View, Text, LayoutChangeEvent, useColorScheme } from "react-native";
import Svg, { Path } from "react-native-svg";
import { Calendar, MapPin, Ticket } from "lucide-react-native";
import { cn } from "@/lib/utils";

const TICKET_RADIUS = 16;
const NOTCH_RADIUS = 9;
const STUB_WIDTH = 96;
const BAR_COUNT = 18;
// Kept deliberately thin (1-3px): at a 64px-tall barcode box, taller bars
// (this used 4-22px before) don't fit 18 of them stacked and overflow the
// box, which is why the barcode wasn't rendering as anything recognizable.
const BAR_MIN_HEIGHT = 1;
const BAR_MAX_HEIGHT = 3;

export type EventTicketStubSide = "left" | "right";
export type EventTicketPerforation = "dashed" | "solid" | "none";

export interface EventTicketProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  title: string;
  date: string;
  venue: string;
  seat?: string;
  code?: string;
  stubSide?: EventTicketStubSide;
  perforation?: EventTicketPerforation;
}

// A closed circle subpath at (cx, cy) with radius r, wound the same direction
// as the outer body path. Combined with fillRule="evenodd" on the parent
// <Path>, any subpath that overlaps the body punches a hole through it.
function notchHole(cx: number, cy: number, r: number) {
  return `M${cx - r} ${cy} a${r} ${r} 0 1 0 ${r * 2} 0 a${r} ${r} 0 1 0 ${-r * 2} 0 Z`;
}

// Builds the full ticket silhouette: a rounded-rect body plus two circular
// notches straddling the top and bottom edge at tearX (the seam where the
// stub meets the main section) -- the classic "torn ticket" cutout look.
function buildSilhouettePath(width: number, height: number, radius: number, tearX: number, notchRadius: number) {
  const r = Math.min(radius, width / 2, height / 2);
  const body = [
    `M${r} 0`,
    `H${width - r}`,
    `A${r} ${r} 0 0 1 ${width} ${r}`,
    `V${height - r}`,
    `A${r} ${r} 0 0 1 ${width - r} ${height}`,
    `H${r}`,
    `A${r} ${r} 0 0 1 0 ${height - r}`,
    `V${r}`,
    `A${r} ${r} 0 0 1 ${r} 0`,
    "Z",
  ].join(" ");
  if (notchRadius <= 0) return body;
  return `${body} ${notchHole(tearX, 0, notchRadius)} ${notchHole(tearX, height, notchRadius)}`;
}

// Deterministically derives a sequence of bar heights from a seed string, so
// the same code always renders the same barcode instead of a random one.
function buildBarHeights(seed: string, count: number) {
  const source = seed.length > 0 ? seed : "aniui";
  const span = BAR_MAX_HEIGHT - BAR_MIN_HEIGHT;
  return Array.from({ length: count }, (_, i) => {
    const code = source.charCodeAt(i % source.length);
    return BAR_MIN_HEIGHT + ((code * 7 + i * 11) % (span + 1));
  });
}

export function EventTicket({
  className,
  title,
  date,
  venue,
  seat,
  code,
  stubSide = "right",
  perforation = "dashed",
  ...props
}: EventTicketProps) {
  const [size, setSize] = useState({ width: 0, height: 0 });
  const dark = useColorScheme() === "dark";
  const surfaceColor = dark ? "#09090b" : "#ffffff";
  const strokeColor = dark ? "#27272a" : "#e4e4e7";

  const onLayout = (e: LayoutChangeEvent) => {
    const { width, height } = e.nativeEvent.layout;
    if (width !== size.width || height !== size.height) setSize({ width, height });
  };

  const tearX = stubSide === "right" ? size.width - STUB_WIDTH : STUB_WIDTH;
  const path =
    size.width > 0 && size.height > 0
      ? buildSilhouettePath(size.width, size.height, TICKET_RADIUS, tearX, NOTCH_RADIUS)
      : null;
  const bars = code ? buildBarHeights(code, BAR_COUNT) : [];

  const tearBorderClass =
    perforation === "none"
      ? ""
      : cn(
          stubSide === "right" ? "border-l" : "border-r",
          perforation === "dashed" ? "border-dashed" : "border-solid",
          "border-border"
        );

  const main = (
    <View className="flex-1 gap-2 p-4">
      <View className="flex-row items-center gap-2">
        <Ticket size={16} color="#71717a" strokeWidth={2} />
        <Text className="text-xs uppercase tracking-widest text-muted-foreground">Event Ticket</Text>
      </View>
      <Text className="text-lg font-semibold text-card-foreground">{title}</Text>
      <View className="flex-row items-center gap-2">
        <Calendar size={14} color="#71717a" strokeWidth={2} />
        <Text className="text-sm text-muted-foreground">{date}</Text>
      </View>
      <View className="flex-row items-center gap-2">
        <MapPin size={14} color="#71717a" strokeWidth={2} />
        <Text className="text-sm text-muted-foreground">{venue}</Text>
      </View>
      {seat ? <Text className="text-sm font-medium text-foreground">Seat {seat}</Text> : null}
    </View>
  );

  const stub = (
    <View className={cn("w-24 items-center justify-center gap-3 p-3", tearBorderClass)}>
      {code ? (
        <>
          <View className="h-16 w-8 flex-col justify-between">
            {bars.map((height, i) => (
              <View key={i} className="w-full bg-foreground" style={{ height }} />
            ))}
          </View>
          {/* Fixed-height box, not just the rotated Text alone: a rotate
              transform doesn't reflow surrounding layout to match its visual
              footprint, so an unwrapped rotated text keeps its pre-rotation
              (wide, short) footprint and can overlap the barcode above it. */}
          <View className="h-20 items-center justify-center">
            <Text className="rotate-90 text-[10px] font-mono text-muted-foreground">{code}</Text>
          </View>
        </>
      ) : null}
    </View>
  );

  return (
    <View className={cn("overflow-hidden rounded-2xl", className)} accessibilityRole="summary" {...props}>
      <View onLayout={onLayout} className="relative flex-row">
        {path ? (
          <Svg width={size.width} height={size.height} style={{ position: "absolute" }}>
            <Path d={path} fill={surfaceColor} stroke={strokeColor} strokeWidth={1} fillRule="evenodd" />
          </Svg>
        ) : null}
        {stubSide === "right" ? (
          <>
            {main}
            {stub}
          </>
        ) : (
          <>
            {stub}
            {main}
          </>
        )}
      </View>
    </View>
  );
}