Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Tear coupon

New

Guest-pass stubs that peel off a ticket like stickers, then hand you a share sheet.

tear-coupon/tear-coupon.tsx
Live · interactive

Share a little cozy

Cozy Crowd

Six playful themes, free for a friend to try.

GUEST 01 / 03Used
GUEST 02 / 03Used
GUEST 03 / 03Used

Each pass gives a friend 30 days of cozy, free. You can share three a year.

Passes are good for 60 days.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Giving away a small, countable thing: guest passes, invites, referral codes, gift days.
  • When the act of handing it over should feel deliberate and a little special, not like a menu item.
  • Two to five items of the same kind, where seeing what is left (and what is used) matters.
  • A touch-first screen, where a sideways drag is natural; a tap or Enter peels it too.

When not to use

  • Plain sharing of a link or a file. Use the system share sheet or a Copy link button.
  • Many items, or items that differ: a list with a Share button on each row reads faster.
  • Anything that can’t be undone or costs money. A playful peel hides the weight; use a confirmation.
  • Dense dashboards or settings, where a 290 px phone mock-up and its motion take too much room.

You can add this component with the shadcn CLI or by hand:

Into components/tear-coupon of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/tear-coupon.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

guest-passes.tsx
import { TearCoupon } from './tear-coupon';export function GuestPasses() {  return (    <TearCoupon      autoplay={false}      onShare={(pass, target) => console.log(`Pass ${pass} shared: ${target}`)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A ticket of guest passes on a phone screen, each pass a stub under a dotted perforation. Drag one sideways and the paper rolls up under your finger, showing its blank back and a soft shadow. Roll up a third of it and it tears away, its place reads Used, and a share sheet slides up; let go early and it lies back flat.

  • A press starts the roll where you touch, and dragging sideways rolls the paper up under the finger. The stub is cut away only between the press and the finger, so the rest of it stays flat on both sides.
  • The roll is a fixed-size box of the paper’s blank back, shaded like a cylinder. It widens as it gathers paper and stretches down past the stub, the way paper lifted towards you would, with a soft shadow on the bare side. A drag writes a few CSS variables on the stub’s slot without re-rendering; the cut is a clip-path polygon.
  • Let go with less than a third rolled up and it rolls back and lies flat. Let go past that, flick it, or drag the roll near the far edge, and it tears: the roll runs to the far edge, the rest of the stub goes into it, and it lifts off. A requestAnimationFrame loop runs only during the roll-back or the tear.
  • After the tear, the stub’s place reads Used and has a Restore button, and the ticket’s corners and shadow re-form around the pieces left. The share sheet then slides up over a dimmed screen.
  • The phone is a 390 × 844 screen measured in points times one unit, laid out at the size it is shown rather than scaled down with a transform. Only the stub being peeled, the open sheet and the pointer get layers of their own.
  • Every colour is a light-dark() token on the stage, so it follows the page’s color-scheme, or the colorScheme prop. At night the phone’s app goes dark as an app’s own dark mode would: a warm near-black screen, the ticket on dark paper with light ink and a light barcode, and a dark share sheet; the crowd art stays as drawn.
PropTypeDefaultDescription
titlestring'Cozy Crowd'The ticket’s name, under its art.
offerstring'30 days of cozy for free.'What each pass gives, on its stub.
defaultStateTearCouponState—Opens in this state, held still until the reader acts on it: passes (each 'ready' or 'used', in order), sheet (the share sheet up for this pass, from 1, which is then used) and peel ({ pass, share, side? }: a pass held part-way peeled, share from 0 to 1, lifting from the right or left). Turns the demo off.
onTear(pass: number) => void—Called when a pass is torn off, with its number (from 1).
onShare(pass: number, target: string) => void—Called when a torn pass is shared, with its number and the target picked in the sheet.
autoplaybooleantrueWhile on screen and untouched, a ghost pointer peels a pass, shares it and puts it back.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

tear-coupon.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type FocusEvent,  type KeyboardEvent,  type PointerEvent,} from 'react';import { Crowd } from './crowd';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { ShareSheet } from './share-sheet';import { useAutoplay } from './use-autoplay';import styles from './tear-coupon.module.css';/* * Tear coupon: a ticket of guest passes on a phone screen. Each pass is a stub under a dotted * perforation; drag one sideways and it peels off like a sticker. *
tear-coupon.module.css
/* Tear coupon (tear-coupon.tsx): a light stage with a phone in its middle, or at night a   near-black one. The phone is a 390 × 844 screen in a 414 × 868 body, and every measure inside   it is a number of the screen's points times --u, the size of one point on the stage. It is   laid out at the size it is shown, not scaled down by a transform, so that what moves inside it   is drawn at that size too.   Memory and layout. The stage is one layout boundary of a fixed height, so nothing that changes   inside it lays out the page around it. What moves stays off the page's tiles: a stub being   peeled is a small layer of its own, with its curl and the curl's shadow (boxes of a fixed size,   moved and stretched by transforms) on layers inside it; the share sheet is a layer while it is   up; the demo's pointer has its own. A peel writes a few variables on the stub's slot (--from,   --cut, --curl-x, --cast-x and --bend-x in px, --curl-s, --curl-h, --cast-s and --lift as   numbers), which repaint that stub alone.   Nothing inside an SVG ever changes.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme). At night the stage goes dark and the app on the phone follows, as an   app's dark mode would: a warm near-black screen, the ticket on dark paper with light ink, a   dark share sheet. The phone itself, the crowd art and the share sheet's pictures (people and   app icons) stay as they are. */.stage {  /* The stage, its dots and its edge. */
share-sheet.tsx
import type { Ref } from 'react';import styles from './share-sheet.module.css';/* * The share sheet that slides up once a stub is torn off: people nearby, apps, quick actions and * a list of more, all made up. Every target is a button that hands its name to `onPick`. The * sheet is always in the page, out of view below the screen while closed (hidden and inert), so * opening it moves one layer and adds nothing. *//** * People to share with: illustrated, with a shirt, a skin tone and a hair style each. They are * pictures, the same in light and dark, like the app icons below. */const PEOPLE = [  { name: 'Iris Vale', bg: '#f3dfd6', skin: '#f1c8a8', hair: '#5b4636', style: 'bob' },  { name: 'Remy Sol', bg: '#dfe6f3', skin: '#c99a74', hair: '#2f2a28', style: 'short' },  { name: 'Noor Haddad', bg: '#e6eedb', skin: '#e2b48e', hair: '#3b2c25', style: 'long' },] as const;type Person = (typeof PEOPLE)[number];function Avatar({ person }: { person: Person }) {  const { bg, skin, hair, style } = person;
share-sheet.module.css
/* The share sheet (share-sheet.tsx), measured in the screen's points times --u (a 390 × 844   screen, set by the phone around it). It slides   up, and stays up, on a layer of its own; closed, it waits below the screen, hidden, and gives   that layer back. Its colours are the stage's tokens (tear-coupon.module.css), light or dark;   the pictures of people and the app icons stay as they are. */.sheet {  position: absolute;  z-index: 3;  top: calc(64 * var(--u));  right: calc(8 * var(--u));  bottom: calc(-40 * var(--u));  left: calc(8 * var(--u));  padding: 0 0 calc(40 * var(--u));  border-radius: calc(40 * var(--u));  background: var(--sheet);  box-shadow:    0 calc(-1 * var(--u)) 0 var(--sheet-edge),    0 calc(-10 * var(--u)) calc(40 * var(--u)) var(--sheet-shadow);  color: var(--sheet-ink);  font:    400 calc(13 * var(--u))/1.2 -apple-system,    BlinkMacSystemFont,    'SF Pro Text',
crowd.tsx
/* * The ticket's header art: a crowd of small, round animals packed shoulder to shoulder in warm, * muted colours, drawn as plain SVG shapes in a 348 × 300 box. Each animal is a head in a 100-unit * box centred on its face, with a body in a shirt under it, placed and scaled into the crowd back * row first, so that each row hides the bodies behind it up to the shoulders. The drawing never * changes, so it is one static SVG. */import type { ReactNode } from 'react';type Place = { x: number; y: number; s: number };/** A body's shirt, plain or striped. */type Shirt = { shirt: string; stripes?: string };const LINE = '#8a7c6d';const DARK = '#5a514a';const CHEEK = '#eabdaa';/** A head's outline style: a warm, dark line. */const ink = {  stroke: LINE,  strokeWidth: 2.6,  strokeLinejoin: 'round',  strokeLinecap: 'round',
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a control and press it, or grab a stub and drag it, calling the * component's own handlers as a hand would. Stopping the demo (the reader takes over, or it * scrolls away) ends the step in progress at once: its promise rejects, which ends the script * right there, and the pointer fades out. * * Nothing the pointer does while the demo plays needs a layout, because one here in the same * frame as one in the phone would make the browser lay out the whole page (see the note at the * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the peel * follows that animation's clock); the press scales a box that keeps a transform at rest, since a * box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes that * swap by their visibility (a change of style inside an SVG lays the SVG out again). */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Each stub is a real button (“Share guest pass 1”): click, Enter or Space peels it with the same motion as a drag. Horizontal drags use touch-action: pan-y, so a vertical swipe still scrolls the page.
  • The share sheet is a modal dialog and the ticket behind it is inert. Opening it moves focus to Close, Escape closes it, and closing moves focus to that pass’s Restore button; after Restore, focus returns to the stub.
  • Tears, shares and restores done by the reader are announced in a polite live region; the demo stays silent. Every button has a visible focus ring, drawn above the paper.
  • Under reduced motion a tear is instant and there is no demo. Without JavaScript the full ticket renders.