Expandable Ticket Card with Spring Height and Blur Reveal

Two event tickets as compact cards: an icon, the event name, the date and a chevron. Tap one and the card grows with a spring to reveal the venue and seat beside an entry code, the running order and two buttons. Each block arrives a beat after the one before, rising a few pixels and sharpening out of a blur. Tap again and it folds back to the header. The first card opens itself shortly after load. On desktop the cards sit side by side and open independently; on phones they stack.

Code

// Adapted from Cult UI (https://cult-ui.com/docs/components/expandable)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";
import { CalendarDays, ChevronDown, MapPin, Ticket, Wallet } from "lucide-react";

const events = [
  {
    id: "harbour",
    name: "Harbour Lights Festival",
    date: "Sat 14 June · doors 17:00",
    place: "Pier 9, Old Docks",
    tone: "from-violet-500 to-fuchsia-500",
    seat: "General standing · Gate C",
    lineup: [
      { time: "18:00", act: "Marlowe & the Tide" },
      { time: "19:30", act: "Saffron Avenue" },
      { time: "21:15", act: "The Paper Lanterns" },
    ],
  },
  {
    id: "strings",
    name: "Strings in the Orangery",
    date: "Thu 3 July · doors 19:30",
    place: "Botanic Gardens",
    tone: "from-emerald-500 to-teal-500",
    seat: "Row F, seats 11 and 12",
    lineup: [
      { time: "20:00", act: "Quartet in D minor" },
      { time: "20:50", act: "Interval, bar in the palm house" },
      { time: "21:10", act: "Octet for strings" },
    ],
  },
];

/** A fixed pattern for the stand-in entry code, so it looks the same on every render. */
const CODE = "1110111 1000101 1011101 0010100 1011011 1000110 1110101".split(" ");

const SPRING = { stiffness: 220, damping: 24, mass: 0.9 };

function TicketCard({ event, startOpen, openAfter }: { event: (typeof events)[number]; startOpen: boolean; openAfter?: number }) {
  const reduce = useReducedMotion();
  const id = useId();
  const [open, setOpen] = useState(startOpen);
  const head = useRef<HTMLDivElement>(null);
  const body = useRef<HTMLDivElement>(null);
  const target = useMotionValue(0);
  const smooth = useSpring(target, SPRING);
  const [ready, setReady] = useState(false);

  // The card height springs between the header alone and the header plus the details.
  useEffect(() => {
    const measure = () => {
      if (!head.current || !body.current) return;
      const next = head.current.offsetHeight + (open ? body.current.offsetHeight : 0);
      target.set(next);
      if (reduce || !ready) smooth.jump(next);
      setReady(true);
    };
    measure();
    const ro = new ResizeObserver(measure);
    if (head.current) ro.observe(head.current);
    if (body.current) ro.observe(body.current);
    return () => ro.disconnect();
  }, [open, reduce, ready, target, smooth]);

  // Opens itself once so a preview shows the movement.
  useEffect(() => {
    if (!openAfter || reduce) return;
    const t = window.setTimeout(() => setOpen(true), openAfter);
    return () => window.clearTimeout(t);
  }, [openAfter, reduce]);

  const reveal = (i: number) => ({
    animate: open ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: 12, filter: "blur(6px)" },
    transition: reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 26, delay: open ? 0.08 + i * 0.06 : 0 },
    initial: false as const,
  });

  return (
    // Three nested shells: a hairline ring, a padded tray and the card itself.
    <div className="w-full max-w-sm rounded-[2rem] p-1.5 shadow-[inset_0_0_2px_1px_rgba(109,40,217,0.12)] ring-1 ring-violet-900/10">
      <div className="rounded-[1.6rem] bg-white shadow-xl shadow-violet-900/10 ring-1 ring-violet-900/5">
        <motion.div style={{ height: ready ? smooth : "auto" }} className="overflow-hidden rounded-[1.6rem]">
          <div ref={head}>
            <button
              type="button"
              aria-expanded={open}
              aria-controls={`${id}-details`}
              onClick={() => setOpen((o) => !o)}
              className="flex w-full items-center gap-4 rounded-[1.6rem] p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-600"
            >
              <span aria-hidden="true" className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br text-white ${event.tone}`}>
                <Ticket className="h-6 w-6" />
              </span>
              <span className="min-w-0 flex-1">
                <span className="block truncate text-base font-semibold text-slate-900">{event.name}</span>
                <span className="mt-0.5 flex items-center gap-1.5 text-sm text-slate-600">
                  <CalendarDays aria-hidden="true" className="h-3.5 w-3.5" />
                  {event.date}
                </span>
              </span>
              <motion.span animate={{ rotate: open ? 180 : 0 }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 24 }} className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-700">
                <ChevronDown aria-hidden="true" className="h-4 w-4" />
              </motion.span>
            </button>
          </div>

          <div ref={body} id={`${id}-details`} className={open ? "" : "invisible"}>
            <div className="space-y-4 px-4 pb-4">
              <motion.div {...reveal(0)} className="flex items-center justify-between gap-3 rounded-2xl bg-slate-50 p-3">
                <div className="min-w-0">
                  <p className="flex items-center gap-1.5 text-sm font-medium text-slate-900">
                    <MapPin aria-hidden="true" className="h-3.5 w-3.5 text-violet-600" />
                    {event.place}
                  </p>
                  <p className="mt-0.5 text-sm text-slate-600">{event.seat}</p>
                </div>
                <div aria-hidden="true" className="grid shrink-0 grid-cols-7 gap-px rounded-md bg-white p-1.5 ring-1 ring-slate-200">
                  {CODE.join("").split("").map((bit, i) => (
                    <span key={i} className={`h-1.5 w-1.5 ${bit === "1" ? "bg-slate-900" : "bg-transparent"}`} />
                  ))}
                </div>
              </motion.div>

              <motion.ol {...reveal(1)} className="space-y-2">
                {event.lineup.map((l) => (
                  <li key={l.time} className="flex items-baseline gap-3 text-sm">
                    <span className="w-11 shrink-0 font-medium tabular-nums text-violet-700">{l.time}</span>
                    <span className="text-slate-700">{l.act}</span>
                  </li>
                ))}
              </motion.ol>

              <motion.div {...reveal(2)} className="flex gap-2">
                <button type="button" tabIndex={open ? 0 : -1} className="flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-slate-900 text-sm font-semibold text-white transition-colors hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2">
                  <Wallet aria-hidden="true" className="h-4 w-4" /> Add to wallet
                </button>
                <button type="button" tabIndex={open ? 0 : -1} className="h-11 rounded-xl border border-slate-300 px-4 text-sm font-semibold text-slate-800 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2">
                  Transfer
                </button>
              </motion.div>
            </div>
          </div>
        </motion.div>
      </div>
    </div>
  );
}

export default function ExpandableTicketCardWithSpringHeightAndBlurReveal() {
  return (
    <section className="bg-violet-50 px-4 py-16 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-4xl">
        <div className="text-center">
          <h2 className="text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Your tickets</h2>
          <p className="mt-3 text-base text-slate-600">Tap a ticket for the entry code, the running order and the door times.</p>
        </div>
        <div className="mt-10 flex flex-col items-center gap-6 md:min-h-[400px] md:flex-row md:items-start md:justify-center">
          <TicketCard event={events[0]} startOpen={false} openAfter={500} />
          <TicketCard event={events[1]} startOpen={false} />
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/expandable-ticket-card-with-spring-height-and-blur-reveal.json

When to use it

How to customize it

Accessibility

Questions

How does the card animate to a height it does not know in advance?

The header and the details are measured with a `ResizeObserver`. The target height is the header alone when closed, or header plus details when open. That number goes into a motion value, and `useSpring` makes the real height chase it.

Why keep the details in the page while the card is closed?

Because they can then be measured before the card opens, so the spring knows where it is going from the first frame. They are hidden with `visibility` and clipped by the card, so they take no space and cannot be focused.

Why a spring instead of a CSS transition on height?

A spring can be interrupted. Tap twice quickly and it turns round from wherever it is, at the speed it was moving, instead of restarting a fixed-length transition.

Can only one card be open at a time?

Each card holds its own `open` state here. For one at a time, lift the state into the parent as the id of the open card and pass `open` and `onToggle` down.

What is the dotted square?

A stand-in for an entry code, drawn from a fixed pattern of small squares. Replace it with your real code image or component.

Adapted from Expandable in Cult UI, used under its open-source licence. Licences and credits

Related: Flip Cards with Details on the Back, FAQ Accordion with Working Open and Close, 3D Tilt Card with Parallax Layers and Glare.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap