# Boarding Pass

> A paper boarding pass with real notches: tap the barcode to scan it and the stub tears off with a stamp.

- Category: Card
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `@shopify/react-native-skia`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add boarding-pass
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/boarding-pass.json
```

## Usage

```tsx
import { BoardingPass } from '@/components/kinetik/boarding-pass';

<BoardingPass
  airline="Northwind"
  flight="KN 1321"
  cabin="Premium"
  booking="AG7890"
  from={{ code: 'CDG', city: 'Paris', time: '14:30' }}
  to={{ code: 'JFK', city: 'New York', time: '17:45' }}
  duration="8h 15m"
  passenger="Olivia Bennett"
  details={[
    { label: 'Gate', value: 'B12' },
    { label: 'Seat', value: '12A' },
  ]}
  onScan={checkIn}
/>;
```

## When to use

- Showing a ticket, pass or booking that the user presents or checks in with.

## When not to use

- As a real scannable code: the barcode is a decorative pattern. Render a real barcode or QR code for gates.

## Notes

- The notches are cut out of the ticket shape, so the pass sits on any background.
- The pass is paper-white on both themes. `accent` colours the striped band.
- Without `onScan` the stub is not a button. `boarded` starts the pass already torn and stamped.
- `barcodeBars(code)` is exported: the same code always draws the same pattern.

## BoardingPassProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `airline` | `string` |  |  |
| `flight` | `string` |  | Flight number under the airline. |
| `from` | `PassStop` |  |  |
| `to` | `PassStop` |  |  |
| `duration` | `string` |  | Flight time in the chip between the codes, e.g. "3h 15m". |
| `passenger` | `string` |  |  |
| `details` | `readonly PassDetail[]` |  | The row of small facts: departure, terminal, gate, seat. |
| `brand`? | `string` | `'Kinetik Air'` | Name and mark at the top. Defaults to "Kinetik Air". |
| `cabin`? | `string` |  | Cabin on the right, e.g. "Premium". |
| `booking`? | `string` |  | Booking reference under the cabin. |
| `passengerLabel`? | `string` | `'Passenger name'` | Label above the passenger. Defaults to "Passenger name". |
| `code`? | `string` |  | Encoded in the barcode's pattern. Defaults to the booking and flight. |
| `boarded`? | `boolean` | `false` | Start already boarded: the stub torn and stamped. |
| `onScan`? | `() => void` |  | Called when the barcode is tapped and scanned. Without it the stub is not a button. |
| `accent`? | `string` | `'hsl(243, 88%, 68%)'` | Colour of the striped band. |
| `haptics`? | `boolean` | `true` | Haptics on scan. Defaults to true. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The pass appears in place and a scan tears and stamps the stub at once, with no scanner line.
- Screen readers: The pass reads as one summary (route, times, passenger, gate and seat); the barcode is a button that becomes "Boarded".
- Touch target: The whole stub, 112 pt tall, is the scan button.

## Performance

The ticket shape is built once per size; the band slides in on arrival and the scan runs one UI-thread timing and spring. Nothing animates at rest.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 1.35× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 158 MB app memory. Measured during scans (scanner line with a blurred glow, then the stub tearing). Frame stats put app work at about 2 ms and render-thread work at 1 ms a frame; the rest is the emulator waiting on its virtual GPU.
- Real-device measurement pending.

## Source

`components/kinetik/boarding-pass.tsx`

```tsx
import {
  BlurMask,
  Canvas,
  DashPathEffect,
  Group,
  Line,
  Path,
  PathOp,
  Skia,
  vec,
  type SkPath,
} from '@shopify/react-native-skia';
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withDelay,
  withSpring,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';
import Svg, { Path as SvgPath } from 'react-native-svg';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type PassStop = {
  /** Airport code, e.g. "CDG". */
  code: string;
  /** City above the code. */
  city: string;
  /** Local time under the code. */
  time: string;
};

export type PassDetail = { label: string; value: string };

export type BoardingPassProps = {
  /** Name and mark at the top. Defaults to "Kinetik Air". */
  brand?: string;
  airline: string;
  /** Flight number under the airline. */
  flight: string;
  /** Cabin on the right, e.g. "Premium". */
  cabin?: string;
  /** Booking reference under the cabin. */
  booking?: string;
  from: PassStop;
  to: PassStop;
  /** Flight time in the chip between the codes, e.g. "3h 15m". */
  duration: string;
  passenger: string;
  /** Label above the passenger. Defaults to "Passenger name". */
  passengerLabel?: string;
  /** The row of small facts: departure, terminal, gate, seat. */
  details: readonly PassDetail[];
  /** Encoded in the barcode's pattern. Defaults to the booking and flight. */
  code?: string;
  /** Start already boarded: the stub torn and stamped. */
  boarded?: boolean;
  /** Called when the barcode is tapped and scanned. Without it the stub is not a button. */
  onScan?: () => void;
  /** Colour of the striped band. */
  accent?: string;
  /** Haptics on scan. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

const BAND = 56;
const CORNER = 20;
const NOTCH = 14;
const STUB_H = 112;
const STRIPE = 22;
/** The barcode's box inside the stub. */
const BAR_X = 36;
const BAR_TOP = 26;
const BAR_BOTTOM = STUB_H - 22;

/**
 * Plane from Tabler Icons (MIT, https://tabler.io/icons), nose right on a 24 pt grid. The small
 * icons are SVG: a Skia canvas each would be a GPU surface apiece for a few pixels.
 */
const PLANE = 'M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3z';
const MARK = 'M0 0h8l8 7-8 7H0l6-7z';

function rectPath(x: number, y: number, w: number, h: number) {
  return Skia.PathBuilder.Make()
    .addRect(Skia.XYWHRect(x, y, w, h))
    .detach();
}
function roundPath(x: number, y: number, w: number, h: number, r: number) {
  return Skia.PathBuilder.Make()
    .addRRect(Skia.RRectXY(Skia.XYWHRect(x, y, w, h), r, r))
    .detach();
}
function notches(w: number, y: number) {
  return Skia.PathBuilder.Make().addCircle(0, y, NOTCH).addCircle(w, y, NOTCH).detach();
}
/** Rounds the corners at one end only, and bites the half circles out of the other. */
function ticketPart(w: number, h: number, roundTop: boolean, top: number) {
  const tall = roundPath(0, roundTop ? top : -CORNER * 2, w, h - top + CORNER * 2, CORNER);
  const cut = Skia.Path.MakeFromOp(tall, rectPath(0, top, w, h - top), PathOp.Intersect) ?? tall;
  return Skia.Path.MakeFromOp(cut, notches(w, roundTop ? h : 0), PathOp.Difference) ?? cut;
}

/** A stable bar pattern for a code: the same text always draws the same barcode. */
export function barcodeBars(code: string, count = 46): number[] {
  let h = 2166136261;
  const bars: number[] = [];
  for (let i = 0; i < count; i++) {
    h ^= code.charCodeAt(i % Math.max(1, code.length)) + i;
    h = Math.imul(h, 16777619) >>> 0;
    bars.push(1 + (h % 3));
  }
  return bars;
}

/**
 * A boarding pass on paper with real notches cut at the tear line. The striped
 * band slides in as the pass rises; tapping the barcode runs a scanner line
 * over it, then the stub tears a little away and takes a Boarded stamp.
 */
export function BoardingPass({
  brand = 'Kinetik Air',
  airline,
  flight,
  cabin,
  booking,
  from,
  to,
  duration,
  passenger,
  passengerLabel = 'Passenger name',
  details,
  code,
  boarded = false,
  onScan,
  accent = 'hsl(243, 88%, 68%)',
  haptics = true,
  style,
}: BoardingPassProps) {
  const { font, space } = useKinetikTheme();
  // A pass is paper on either theme.
  const paper = palettes.light;
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [width, setWidth] = useState(0);
  const [height, setHeight] = useState(0);
  const [scanned, setScanned] = useState(boarded);

  const rise = useSharedValue(reduced ? 1 : 0);
  const slide = useSharedValue(reduced ? 1 : 0);
  const sweep = useSharedValue(0);
  const tear = useSharedValue(boarded ? 1 : 0);

  useEffect(() => {
    if (reduced) return;
    rise.set(withSpring(1, spring('smooth')));
    slide.set(withTiming(1, { duration: 1400, easing: Easing.out(Easing.cubic) }));
  }, [reduced, rise, slide]);

  const scan = () => {
    if (scanned) return;
    setScanned(true);
    onScan?.();
    if (reduced) {
      tear.set(1);
      haptic('success');
      return;
    }
    haptic('tap');
    sweep.set(0);
    sweep.set(withTiming(1, { duration: 900, easing: Easing.inOut(Easing.sin) }));
    tear.set(withDelay(820, withSpring(1, spring('bouncy'))));
    setTimeout(() => haptic('success'), 820);
  };

  const body = useMemo(
    () => (width > 0 && height > 0 ? ticketPart(width, height, true, BAND) : null),
    [width, height],
  );
  const band = useMemo(
    () => (width > 0 ? roundPath(0, 0, width, BAND + CORNER * 2, CORNER) : null),
    [width],
  );
  const stripes = useMemo(() => {
    const b = Skia.PathBuilder.Make();
    if (width <= 0) return b.detach();
    // Diagonal bars from well left of the band, so the slide never shows an edge.
    for (let x = -STRIPE * 8; x < width + BAND; x += STRIPE) {
      b.moveTo(x, BAND + CORNER)
        .lineTo(x + STRIPE / 2, BAND + CORNER)
        .lineTo(x + STRIPE / 2 + BAND + CORNER, 0)
        .lineTo(x + BAND + CORNER, 0)
        .close();
    }
    return b.detach();
  }, [width]);
  const drift = useDerivedValue(() => [{ translateX: (1 - slide.get()) * -STRIPE * 6 }]);

  const lift = useAnimatedStyle(() => ({
    opacity: rise.get(),
    transform: [{ translateY: (1 - rise.get()) * 28 }],
  }));

  const ink = paper.text;
  const muted = paper.textSubtle;
  const label = (text: string, align: 'left' | 'right' = 'left') => (
    <Text style={{ color: muted, fontSize: font.size.sm, textAlign: align }}>{text}</Text>
  );
  const rule = <View style={[styles.rule, { backgroundColor: paper.border }]} />;

  return (
    <Animated.View style={[styles.pass, style, lift]}>
      <View
        accessible
        accessibilityLabel={[
          `Boarding pass, ${brand}`,
          `${airline} flight ${flight}${cabin ? `, ${cabin}` : ''}`,
          `${from.city} ${from.code} ${from.time} to ${to.city} ${to.code} ${to.time}, ${duration}`,
          `${passengerLabel} ${passenger}`,
          details.map((d) => `${d.label} ${d.value}`).join(', '),
        ].join('. ')}
        onLayout={(e) => {
          setWidth(e.nativeEvent.layout.width);
          setHeight(e.nativeEvent.layout.height);
        }}
        style={{
          paddingTop: BAND + space[5],
          paddingHorizontal: space[5],
          paddingBottom: space[5],
        }}
      >
        {body && band ? (
          <Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
            <Group clip={band}>
              <Path path={band} color={accent} />
              <Group transform={drift}>
                <Path path={stripes} color={withAlpha(paper.text, 0.86)} />
              </Group>
            </Group>
            <Path path={body} color={paper.surfaceRaised} />
          </Canvas>
        ) : null}

        <View style={[styles.row, { gap: 8 }]}>
          <Svg width={16} height={14} viewBox="0 0 16 14" pointerEvents="none">
            <SvgPath d={MARK} fill={ink} />
          </Svg>
          <Text style={{ color: ink, fontSize: font.size.lg, fontWeight: font.weight.bold }}>
            {brand}
          </Text>
        </View>

        <View style={[styles.split, { marginTop: space[4] }]}>
          <View>
            <View style={[styles.row, { gap: 6 }]}>
              <View style={[styles.logo, { backgroundColor: accent }]} />
              <Text style={{ color: ink, fontSize: font.size.md - 1 }}>{airline}</Text>
            </View>
            {label(flight)}
          </View>
          {cabin || booking ? (
            <View style={styles.end}>
              {cabin ? (
                <Text style={{ color: ink, fontSize: font.size.md - 1 }}>{cabin}</Text>
              ) : null}
              {booking ? label(booking, 'right') : null}
            </View>
          ) : null}
        </View>

        {rule}

        <View style={[styles.split, { alignItems: 'flex-end' }]}>
          <View>
            {label(from.city)}
            <Text style={[styles.code, { color: ink }]}>{from.code}</Text>
            <Text style={[styles.time, { color: withAlpha(paper.text, 0.4) }]}>{from.time}</Text>
          </View>
          <View
            style={[
              styles.chip,
              { backgroundColor: withAlpha(paper.azure, 0.12), marginBottom: 28 },
            ]}
          >
            <Svg width={12} height={12} viewBox="0 0 24 24" pointerEvents="none">
              <SvgPath
                d={PLANE}
                fill="none"
                stroke={ink}
                strokeWidth={2.2}
                strokeLinejoin="round"
              />
            </Svg>
            <Text style={{ color: ink, fontSize: 11, fontWeight: font.weight.medium }}>
              {duration}
            </Text>
          </View>
          <View style={styles.end}>
            {label(to.city, 'right')}
            <Text style={[styles.code, { color: ink }]}>{to.code}</Text>
            <Text style={[styles.time, { color: withAlpha(paper.text, 0.4) }]}>{to.time}</Text>
          </View>
        </View>

        {rule}

        {label(passengerLabel)}
        <Text
          style={{
            color: ink,
            fontSize: font.size.xl,
            fontWeight: font.weight.medium,
            marginTop: 2,
          }}
        >
          {passenger}
        </Text>

        {rule}

        <View style={styles.split}>
          {details.map((d) => (
            <View key={d.label}>
              {label(d.label)}
              <Text
                style={{
                  color: ink,
                  fontSize: font.size.xl,
                  fontWeight: font.weight.medium,
                  marginTop: 2,
                }}
              >
                {d.value}
              </Text>
            </View>
          ))}
        </View>
      </View>

      <Stub
        width={width}
        code={code ?? `${booking ?? ''}${flight}`}
        scanned={scanned}
        interactive={!!onScan}
        sweep={sweep}
        tear={tear}
        onPress={scan}
      />
    </Animated.View>
  );
}

function Stub({
  width,
  code,
  scanned,
  interactive,
  sweep,
  tear,
  onPress,
}: {
  width: number;
  code: string;
  scanned: boolean;
  interactive: boolean;
  sweep: SharedValue<number>;
  tear: SharedValue<number>;
  onPress: () => void;
}) {
  const { font } = useKinetikTheme();
  const paper = palettes.light;
  const shape = useMemo<SkPath | null>(
    () => (width > 0 ? ticketPart(width, STUB_H, false, 0) : null),
    [width],
  );
  const bars = useMemo(() => {
    const b = Skia.PathBuilder.Make();
    if (width <= 0) return b.detach();
    const pattern = barcodeBars(code);
    const total = pattern.reduce((s, n) => s + n * 2, 0);
    const unit = (width - BAR_X * 2) / total;
    let x = BAR_X;
    pattern.forEach((n, i) => {
      // Alternate thick and thin bars for a little rhythm.
      const w = unit * (i % 4 === 0 ? n + 0.6 : n * 0.7);
      b.addRect(Skia.XYWHRect(x, BAR_TOP, w, BAR_BOTTOM - BAR_TOP));
      x += unit * n * 2;
    });
    return b.detach();
  }, [width, code]);

  const laserY = useDerivedValue(() => {
    const s = sweep.get();
    const t = s < 0.5 ? s * 2 : 2 - s * 2;
    return BAR_TOP + (BAR_BOTTOM - BAR_TOP) * t;
  });
  // The line itself stays put and its group moves, so the blurred glow is one cached mask.
  const laserAt = useDerivedValue(() => [{ translateY: laserY.get() }]);
  const laserOn = useDerivedValue(() => (sweep.get() > 0 && sweep.get() < 1 ? 1 : 0));

  const torn = useAnimatedStyle(() => ({
    transform: [{ translateY: tear.get() * 8 }, { rotate: `${tear.get() * 2.2}deg` }],
  }));
  const stamp = useAnimatedStyle(() => ({
    opacity: tear.get() > 0.02 ? Math.min(1, tear.get() * 1.5) : 0,
    transform: [{ scale: 1.6 - Math.min(1, tear.get()) * 0.6 }, { rotate: '-8deg' }],
  }));

  return (
    <Animated.View style={[styles.stub, { transformOrigin: 'left top' }, torn]}>
      {shape ? (
        <Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
          <Path path={shape} color={paper.surfaceRaised} />
          <Line
            p1={vec(NOTCH + 8, 0.5)}
            p2={vec(width - NOTCH - 8, 0.5)}
            color={paper.borderStrong}
            strokeWidth={1}
          >
            <DashPathEffect intervals={[5, 4]} />
          </Line>
          <Group opacity={scanned ? 0.35 : 1}>
            <Path path={bars} color={paper.text} />
          </Group>
          <Group opacity={laserOn} transform={laserAt}>
            <Line
              p1={vec(BAR_X - 10, 0)}
              p2={vec(width - BAR_X + 10, 0)}
              color={paper.danger}
              strokeWidth={6}
            >
              <BlurMask blur={5} style="normal" />
            </Line>
            <Line
              p1={vec(BAR_X - 10, 0)}
              p2={vec(width - BAR_X + 10, 0)}
              color={paper.danger}
              strokeWidth={1.5}
            />
          </Group>
        </Canvas>
      ) : null}
      <Pressable
        accessibilityRole={interactive ? 'button' : undefined}
        accessibilityLabel={scanned ? 'Boarded' : 'Barcode'}
        accessibilityHint={interactive && !scanned ? 'Scans the boarding pass' : undefined}
        accessibilityState={interactive ? { disabled: scanned } : undefined}
        disabled={!interactive || scanned}
        onPress={onPress}
        style={StyleSheet.absoluteFill}
      />
      <Animated.View
        pointerEvents="none"
        style={[
          styles.stamp,
          { borderColor: paper.success, backgroundColor: paper.surfaceRaised },
          stamp,
        ]}
      >
        <View style={[styles.tick, { borderColor: paper.success }]} />
        <Text
          style={{
            color: paper.success,
            fontSize: font.size.sm,
            fontWeight: font.weight.bold,
            letterSpacing: 1.5,
          }}
        >
          BOARDED
        </Text>
      </Animated.View>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  pass: {},
  row: { flexDirection: 'row', alignItems: 'center' },
  split: { flexDirection: 'row', justifyContent: 'space-between' },
  end: { alignItems: 'flex-end' },
  logo: { width: 14, height: 14, borderRadius: 7 },
  rule: { height: StyleSheet.hairlineWidth, marginVertical: 14 },
  code: { fontSize: 32, lineHeight: 36, fontWeight: '500', letterSpacing: -0.5 },
  time: { fontSize: 28, lineHeight: 32, fontWeight: '500', letterSpacing: -0.5 },
  chip: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 4,
    paddingHorizontal: 9,
    height: 22,
    borderRadius: 11,
  },
  stub: { height: STUB_H },
  stamp: {
    position: 'absolute',
    alignSelf: 'center',
    top: STUB_H / 2 - 18,
    height: 36,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
    paddingHorizontal: 14,
    borderRadius: 8,
    borderWidth: 2,
  },
  tick: {
    width: 12,
    height: 6,
    borderLeftWidth: 2.5,
    borderBottomWidth: 2.5,
    marginTop: -3,
    transform: [{ rotate: '-45deg' }],
  },
});
```
