Clear Acrylic ID Passes in a Slotted Block That Lift out and Turn, with Neon Edges and a Floating Print

Three event passes cut from thick clear plastic, standing at an angle in diagonal slots in a brushed aluminium block on a dark blue stage. Each has a portrait printed on the back of the sheet and the event name, role and person's name printed in white on the front, so the lettering floats a sheet's thickness above the photograph and slides across it as the pass turns. The cut edges glow lime, ice blue or orange the way fluorescent sheet does, a clear margin runs round the print with a faint mirror image of it bent into the edge, and there is a strap slot at the top. One pass is always lifted out: it rises on a spring, swings round to face you and rocks slowly, while its empty slot shows in the block and its colour fades from the metal underneath. The scene picks the next pass by itself every few seconds and leans a little with the pointer. On a phone the whole block is scaled down to the width.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

// One entry per pass. `edge` is the colour of the fluorescent sheet it is cut from.
const passes = [
  {
    name: "Tobi Adeyemi",
    role: "SPEAKER",
    no: "014",
    edge: "#c4ff3d",
    tone: "#8a2bd6",
    photo: "https://images.pexels.com/photos/9363613/pexels-photo-9363613.jpeg?auto=compress&cs=tinysrgb&w=600",
    alt: "A man with a round afro in a white shirt, lit violet against a bright purple wall",
    at: "50% 18%",
  },
  {
    name: "Mara Vidal",
    role: "CREW",
    no: "102",
    edge: "#4fd8ff",
    tone: "#c40f3c",
    photo: "https://images.pexels.com/photos/21352926/pexels-photo-21352926.jpeg?auto=compress&cs=tinysrgb&w=600",
    alt: "A woman in a green top and black jacket with one arm raised behind her head, against a red backdrop",
    at: "50% 12%",
  },
  {
    name: "Sam Okoro",
    role: "ARTIST",
    no: "057",
    edge: "#ff8a2a",
    tone: "#1f4a37",
    photo: "https://images.pexels.com/photos/7301578/pexels-photo-7301578.jpeg?auto=compress&cs=tinysrgb&w=600",
    alt: "A man seen from behind his shoulder in a blue knitted beanie and jumper, against a dark green wall",
    at: "60% 20%",
  },
];
const event = { name: "OFFCUT", year: "26" };
const BW = 188; // pass width
const BH = 268; // pass height
const THICK = 16; // sheet thickness
const RIM = 12; // clear margin round the print
const TOP = 36; // clear margin above the print, where the strap slot is cut
const GAP = 208; // distance between pass centres
const REST = -22; // angle a pass sits at in its slot, in degrees
const LIFT = 58; // how far the chosen pass rises
const HOLD = 3400; // milliseconds each pass is held up while it plays by itself
const BLOCK = { w: 644, h: 38, d: 96 };
const SCENE_H = BH + LIFT + BLOCK.h + 8;
const PW = BW - RIM * 2, PH = BH - TOP - RIM; // size of the printed photograph

export default function ClearAcrylicIdPasses() {
  const root = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const [picked, setPicked] = useState(1);
  const [scale, setScale] = useState(1);
  const touched = useRef(0);
  const mx = useMotionValue(0), my = useMotionValue(0);
  const turn = useSpring(mx, { stiffness: 60, damping: 14 });
  const tip = useSpring(my, { stiffness: 60, damping: 14 });

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const fit = () => setScale(Math.min(1.36, (el.clientWidth - 32) / BLOCK.w));
    fit();
    const ro = new ResizeObserver(fit); ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Plays by itself until someone picks a pass, and again a while after they stop.
  useEffect(() => {
    if (reduce) return;
    const id = window.setInterval(() => {
      if (document.hidden || performance.now() - touched.current < 9000) return;
      setPicked((p) => (p + 1) % passes.length);
    }, HOLD);
    return () => window.clearInterval(id);
  }, [reduce]);

  const pick = (i: number) => { touched.current = performance.now(); setPicked(i); };
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 120, damping: 13, mass: 1.1 };
  const layers = Array.from({ length: THICK / 2 - 1 }, (_, i) => -THICK / 2 + 2 + i * 2);

  return (
    <div
      ref={root}
      onPointerMove={(e) => {
        if (reduce) return;
        const r = e.currentTarget.getBoundingClientRect();
        mx.set(((e.clientX - r.left) / r.width - 0.5) * 16); my.set(((e.clientY - r.top) / r.height - 0.5) * -6);
      }}
      onPointerLeave={() => { mx.set(0); my.set(0); }}
      className="relative flex min-h-[680px] w-full items-center justify-center overflow-hidden bg-[#161924] pb-24 pt-16"
    >
      <style>{`
        @keyframes clear-acrylic-id-passes-sway { 0%, 100% { transform: rotateY(-7deg); } 50% { transform: rotateY(7deg); } }
        .clear-acrylic-id-passes-sway { animation: clear-acrylic-id-passes-sway 5.2s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) { .clear-acrylic-id-passes-sway { animation: none; } }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(70% 60% at 50% 40%, #343a50 0%, #1f2331 48%, #161924 100%)" }} />

      {/* Sized with zoom, not a scale transform, so the lettering is drawn sharp at the final size. */}
      <div className="relative" style={{ zoom: scale }}>
        <div className="relative" style={{ width: BLOCK.w, height: SCENE_H, perspective: 1700, perspectiveOrigin: "50% 30%" }}>
          <motion.div className="absolute inset-0" style={{ transformStyle: "preserve-3d", rotateX: -11, rotateY: turn, x: 0, y: tip }}>
            {/* Shadow of the block on the floor. */}
            <div aria-hidden="true" className="absolute rounded-[50%]" style={{ left: -30, top: SCENE_H - 70, width: BLOCK.w + 60, height: 140, background: "radial-gradient(closest-side, rgba(0,0,0,0.7), rgba(0,0,0,0.3) 60%, rgba(0,0,0,0))", transform: "rotateX(90deg)", filter: "blur(6px)" }} />

            {/* The anodised block: a top with three diagonal slots and a brushed front. */}
            <div aria-hidden="true" className="absolute left-0 rounded-[3px]" style={{ top: SCENE_H - BLOCK.h - BLOCK.d / 2, width: BLOCK.w, height: BLOCK.d, transform: "rotateX(90deg)", background: "linear-gradient(180deg, #a9acb4 0%, #c6c8ce 55%, #dcdde2 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.3)" }}>
              {passes.map((p, i) => (
                <div key={p.no} className="absolute" style={{ left: BLOCK.w / 2 + (i - 1) * GAP - BW / 2 - 3, top: BLOCK.d / 2 - (THICK + 4) / 2, width: BW + 6, height: THICK + 4, transform: `rotate(${-REST}deg)` }}>
                  <div className="absolute rounded-[50%] bg-black transition-opacity duration-500" style={{ left: 6, right: 6, top: -8, height: THICK + 30, filter: "blur(9px)", opacity: picked === i ? 0.35 : 0 }} />
                  <div className="absolute inset-0 rounded-[3px] bg-[#0b0b0d]" style={{ boxShadow: `inset 0 2px 3px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.6), 0 0 ${picked === i ? 10 : 22}px ${picked === i ? 1 : 3}px ${p.edge}${picked === i ? "30" : "66"}` }} />
                </div>
              ))}
            </div>
            <div aria-hidden="true" className="absolute left-0 overflow-hidden rounded-b-[3px]" style={{ top: SCENE_H - BLOCK.h, width: BLOCK.w, height: BLOCK.h, transform: `translateZ(${BLOCK.d / 2}px)`, background: "linear-gradient(180deg, #f4f5f7 0%, #b9bcc3 7%, #8f929a 58%, #5f626a 100%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.9)" }}>
              <div className="absolute inset-0 opacity-60" style={{ backgroundImage: "repeating-linear-gradient(90deg, rgba(255,255,255,0.1) 0 1px, rgba(0,0,0,0.06) 1px 3px)" }} />
              {/* Each sheet throws a little of its colour onto the metal below it. */}
              {passes.map((p, i) => (
                <div key={p.no} className="absolute top-0 h-full transition-opacity duration-500" style={{ left: BLOCK.w / 2 + (i - 1) * GAP - BW / 2 + 6, width: BW - 12, background: `linear-gradient(180deg, ${p.edge}aa 0%, ${p.edge}33 45%, ${p.edge}00 100%)`, filter: "blur(7px)", opacity: picked === i ? 0.3 : 0.75 }} />
              ))}
              <p className="absolute right-4 top-1/2 -translate-y-1/2 font-mono text-[8px] font-semibold tracking-[0.34em] text-[#2a2c33]/70">{event.name} / {event.year}</p>
            </div>

            {passes.map((p, i) => {
              const up = picked === i;
              return (
                <motion.div
                  key={p.no}
                  className="absolute"
                  style={{ left: BLOCK.w / 2 + (i - 1) * GAP - BW / 2, top: SCENE_H - BLOCK.h - BH, width: BW, height: BH, transformStyle: "preserve-3d" }}
                  initial={false}
                  animate={{ y: up ? -LIFT : 0, rotateY: up ? 6 : REST }}
                  transition={spring}
                >
                  <div className={`absolute inset-0 ${up ? "clear-acrylic-id-passes-sway" : ""}`} style={{ transformStyle: "preserve-3d" }}>
                    {/* Back of the sheet: the photograph is printed here and seen through the plastic. */}
                    <div className="absolute inset-0 rounded-[16px]" style={{ transform: `translateZ(${-THICK / 2}px)`, border: `2px solid ${p.edge}`, background: "rgba(255,255,255,0.04)" }}>
                      <div className="absolute overflow-hidden rounded-[5px]" style={{ left: RIM - 2, top: TOP - 2, width: PW, height: PH, backgroundColor: p.tone }}>
                        <img src={p.photo} alt={p.alt} width={PW} height={PH} loading="eager" draggable={false} className="h-full w-full select-none object-cover" style={{ objectPosition: p.at }} />
                        <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0) 26%, rgba(0,0,0,0) 56%, rgba(0,0,0,0.66) 100%)" }} />
                      </div>
                    </div>
                    {/* Shadow that the white ink on the front throws back onto the print. */}
                    <div aria-hidden="true" className="absolute inset-0 text-black/50" style={{ transform: `translateZ(${-THICK / 2 + 1}px)`, filter: "blur(1.4px)" }}>
                      <Ink p={p} />
                    </div>
                    {/* The body of the sheet: stacked outlines that read as one glowing edge from the side. */}
                    {layers.map((z) => (
                      <div key={z} aria-hidden="true" className="absolute inset-0 rounded-[16px]" style={{ transform: `translateZ(${z}px)`, border: `2.5px solid ${p.edge}`, opacity: 0.62, boxShadow: z === 0 ? `0 0 22px 0 ${p.edge}77` : undefined }} />
                    ))}
                    {/* Light bent at the cut edges: the print mirrored into the clear margin. */}
                    <div aria-hidden="true" className="absolute inset-0 overflow-hidden rounded-[16px]" style={{ transform: "translateZ(2px)", opacity: 0.36 }}>
                      {[
                        { box: { left: 3, top: TOP, width: RIM - 3, height: PH }, inner: { left: RIM - 3 - PW, top: 0 }, flip: "scaleX(-1)" },
                        { box: { left: BW - RIM, top: TOP, width: RIM - 3, height: PH }, inner: { left: 0, top: 0 }, flip: "scaleX(-1)" },
                        { box: { left: RIM, top: BH - RIM, width: PW, height: RIM - 3 }, inner: { left: 0, top: 0 }, flip: "scaleY(-1)" },
                      ].map((r, k) => (
                        <div key={k} className="absolute overflow-hidden" style={{ ...r.box, filter: "blur(0.6px) saturate(1.3) brightness(1.15)" }}>
                          <div className="absolute" style={{ ...r.inner, width: PW, height: PH, backgroundImage: `url(${p.photo})`, backgroundSize: "cover", backgroundPosition: p.at, backgroundColor: p.tone, transform: r.flip }} />
                        </div>
                      ))}
                    </div>
                    {/* Front of the sheet: white ink, the strap slot, bevel and gloss. */}
                    <div className="absolute inset-0 overflow-hidden rounded-[16px] text-white" style={{ transform: `translateZ(${THICK / 2}px)`, border: `1.5px solid ${p.edge}`, boxShadow: `inset 0 0 0 1px rgba(255,255,255,0.55), inset 2px 2px 3px rgba(255,255,255,0.6), inset -3px -4px 7px ${p.edge}aa, inset 0 0 18px ${p.edge}2e`, background: "linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02) 30%, rgba(255,255,255,0.05))" }}>
                      <div aria-hidden="true" className="absolute left-1/2 top-[13px] h-[10px] w-[50px] -translate-x-1/2 rounded-full bg-[#1b1e2b]" style={{ boxShadow: `inset 0 2px 2px rgba(0,0,0,0.5), inset 0 -1.5px 0 ${p.edge}, 0 0 0 1.5px ${p.edge}cc, 0 1px 0 2px rgba(255,255,255,0.35)` }} />
                      <Ink p={p} />
                      <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(118deg, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0.12) 16%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 54%, rgba(255,255,255,0.2) 60%, rgba(255,255,255,0.04) 68%, rgba(255,255,255,0) 76%)" }} />
                    </div>
                    <button
                      type="button"
                      onClick={() => pick(i)}
                      aria-pressed={up}
                      aria-label={`${p.name}, ${p.role.toLowerCase()}, pass number ${p.no}. ${up ? "Lifted out." : "Press to lift it out of the block."}`}
                      className="absolute inset-0 cursor-pointer rounded-[16px] outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-4 focus-visible:ring-offset-[#161924]"
                      style={{ transform: `translateZ(${THICK / 2 + 1}px)` }}
                    />
                  </div>
                </motion.div>
              );
            })}
          </motion.div>
        </div>
      </div>

      <p className="pointer-events-none absolute bottom-7 left-7 font-mono text-[10px] font-semibold uppercase leading-5 tracking-[0.22em] text-zinc-100">
        {event.name} {event.year} passes
        <span className="block font-medium text-zinc-400">Pick a pass to lift it out of the block</span>
      </p>
    </div>
  );
}

// Everything printed in white on the front. Drawn twice: once as ink and once, blurred, as its shadow.
function Ink({ p }: { p: (typeof passes)[number] }) {
  return (
    <div className="absolute" style={{ left: RIM, top: TOP, width: PW, height: PH }}>
      <p className="absolute left-[11px] top-[10px] text-[11px] font-black tracking-[0.26em]">{event.name}</p>
      <p className="absolute right-[11px] top-[10px] font-mono text-[10px] font-bold tracking-[0.1em]">{event.year}</p>
      <p className="absolute bottom-[40px] left-[11px] text-[7.5px] font-bold tracking-[0.3em]">{p.role}</p>
      <p className="absolute bottom-[14px] left-[10px] right-[10px] text-[21px] font-bold leading-[0.95] tracking-[-0.02em]">{p.name}</p>
      <p className="absolute bottom-[42px] right-[11px] font-mono text-[7.5px] font-bold tracking-[0.16em]">No. {p.no}</p>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/clear-acrylic-id-passes-in-a-slotted-block-that-lift-out-and-turn-with-neon-edges-and-a-floating-print.json

When to use it

How to customize it

Accessibility

Questions

How is the thickness of the plastic drawn?

Between the back and the front of each pass sit seven empty rounded rectangles, two pixels apart, each with only a coloured border. Face on they hide behind one another. As the pass turns inside a `preserve-3d` parent the borders separate and read as one solid, glowing edge that follows the rounded corners.

Why does the name seem to float above the photograph?

Because it does. The photograph is on a layer pushed back by half of `THICK` and the white lettering is on a layer pulled forward by the same amount. A blurred dark copy of the lettering lies one pixel in front of the photograph as its shadow. When the pass rotates the browser moves the three layers by different amounts, which is the parallax you see in a real printed sheet.

What is the faint picture in the clear margin?

Thick acrylic bends light at a cut edge, so you see a squeezed mirror of whatever is next to it. Three narrow strips sit in the side and bottom margins, each holding a second copy of the portrait flipped with `scaleX(-1)` or `scaleY(-1)` and lined up so the flip happens exactly at the edge of the print.

Can I show more than three passes?

Yes, add entries to `passes`. The slots and passes are spaced by `GAP` from the centre of the block, so widen `BLOCK.w` by one `GAP` for each extra pass and change the two places that use `i - 1` to centre the row on the new count.

Does the lifted pass pass through the block?

No. Every pass stands with its bottom edge on the top face, over a dark slot drawn at the same angle. Lifting moves it straight up, so no two surfaces ever cross and the browser never has to split a layer to sort them.

Related: Engraved Steel Membership Card That Turns on Its Edge and Catches a Fixed Lamp in Its Lettering, Matchbox Card with a Drawer That Slides out and a Photo Print That Stands up from the Matches.

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