Skip to content

FreeIn reviewv1.0.0

Envelope

An envelope that opens on a press: the flap swings back, the card rises out and comes forward.
npx shadcn@latest add @vibebox/envelope

Controls

<Envelope />

About

Layers in a perspective box: the back of the envelope and its inside, the card, the pocket (the front, cut as a V from the two sides and the bottom with a clip-path) and the flap, a triangle hinged at the top edge that rotates 180 degrees about X. The card translates up and forward while the flap swings, with a short delay so the order reads: flap, then card. The seal sits on the join and fades. Everything is transforms and opacity; the whole envelope is one button with aria-expanded.

Use it when

  • A wedding, party or event page wants the invitation to be opened.
  • A voucher, gift card or thank-you page wants a small ceremony.
  • A newsletter or letter-from-the-founder page wants a quiet reveal.

Skip it when

  • The content must be visible without an interaction on first view (it is readable, but partly covered while closed).
  • The page is already full of motion.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/envelope
  3. 3Use it:
    import { Envelope } from "@/components/vibebox/envelope";
    
    const colors = { paper: "#f6d8d8", inside: "#e8b9bb", card: "#fffaf6", seal: "#8c2f4a" };
    
    <Envelope seal="A" colors={colors}>
      <p className="text-center font-serif text-2xl">You are invited</p>
    </Envelope>

Code

2 files
components/vibebox/envelope/envelope.client.tsx
/**
 * VibeBox Motion · Envelope
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useId, useState, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type EnvelopeProps = {
  /** What is on the card inside (an invitation, a voucher, a note). */
  children: ReactNode;
  /** The envelope's colours: its paper, the darker inside of the flap, and the card. */
  colors?: { paper?: string; inside?: string; card?: string; seal?: string };
  /** A short mark on the wax seal (one or two characters). */
  seal?: string;
  /** Starts opened. */
  defaultOpen?: boolean;
  /** Called after it opens or closes. */
  onToggle?: (open: boolean) => void;
  /** Words for the button. */
  openLabel?: string;
  closeLabel?: string;
  className?: string;
};

/*
 * An envelope that opens on a press: the flap swings back in 3D, the card rises out of the
 * pocket and settles in front of the envelope. It is one button, so keyboard and touch work the
 * same; the card's content is always in the page, so nothing is hidden from search or readers.
 */
export function Envelope({
  children,
  colors,
  seal = "V",
  defaultOpen = false,
  onToggle,
  openLabel = "Open the envelope",
  closeLabel = "Close the envelope",
  className,
}: EnvelopeProps) {
  const [open, setOpen] = useState(defaultOpen);
  const id = useId();
  const toggle = () => {
    setOpen((value) => {
      onToggle?.(!value);
      return !value;
    });
  };
  const paper = colors?.paper ?? "#f1e6d4";
  const inside = colors?.inside ?? "#d9c7a8";
  const card = colors?.card ?? "#fffdf8";
  const wax = colors?.seal ?? "#b3262e";
  const style = {
    "--env-paper": paper,
    "--env-inside": inside,
    "--env-card": card,
    "--env-seal": wax,
  } as CSSProperties;

  return (
    <div data-vb="envelope" data-open={open ? "true" : "false"} className={cn("relative mx-auto w-full max-w-[520px]", className)} style={style}>
      <div className="relative aspect-[3/2] w-full [perspective:1400px]">
        {/* The back of the envelope and its inside. */}
        <span aria-hidden="true" className="absolute inset-0 rounded-[6px] shadow-[0_30px_60px_-30px_rgb(0_0_0/0.55)]" style={{ background: "var(--env-inside)" }} />

        {/* The card: it rises out of the pocket and comes forward. */}
        <div
          id={`${id}-card`}
          className="absolute inset-x-[7%] bottom-[8%] top-[10%] overflow-hidden rounded-[4px] p-[5%] text-neutral-900 shadow-[0_8px_22px_-10px_rgb(0_0_0/0.45)] transition-transform duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
          style={{
            background: "var(--env-card)",
            transform: open ? "translateY(-40%) translateZ(30px) scale(1.03)" : "translateY(0) translateZ(0)",
            transitionDelay: open ? "220ms" : "0ms",
            zIndex: open ? 3 : 1,
          }}
        >
          {children}
        </div>

        {/* The pocket: the envelope's front, cut as a V from the two sides and the bottom. */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 z-[2] rounded-[6px]"
          style={{
            background: "linear-gradient(160deg, color-mix(in srgb, var(--env-paper), #fff 12%), var(--env-paper) 55%, color-mix(in srgb, var(--env-paper), #000 8%))",
            clipPath: "polygon(0 0, 50% 52%, 100% 0, 100% 100%, 0 100%)",
          }}
        />
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 z-[2] rounded-[6px]"
          style={{
            background: "linear-gradient(90deg, rgb(0 0 0 / 0.1), transparent 30%, transparent 70%, rgb(0 0 0 / 0.08))",
            clipPath: "polygon(0 0, 50% 52%, 100% 0, 100% 100%, 0 100%)",
          }}
        />

        {/* The flap: hinged at the top edge. Its two faces are separate, so the paper shows going up and the inside shows once it has turned over. */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-0 top-0 h-[56%] origin-top transition-transform duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none [transform-style:preserve-3d]"
          style={{
            transform: open ? "rotateX(180deg)" : "rotateX(0deg)",
            zIndex: open ? 0 : 4,
            transitionDelay: open ? "0ms" : "280ms",
          }}
        >
          <span
            className="absolute inset-0 [backface-visibility:hidden]"
            style={{
              background: "linear-gradient(180deg, color-mix(in srgb, var(--env-paper), #fff 14%), var(--env-paper) 70%, color-mix(in srgb, var(--env-paper), #000 10%))",
              clipPath: "polygon(0 0, 100% 0, 50% 100%)",
            }}
          />
          <span
            className="absolute inset-0 [backface-visibility:hidden]"
            style={{
              background: "linear-gradient(180deg, color-mix(in srgb, var(--env-inside), #000 12%), var(--env-inside))",
              clipPath: "polygon(0 0, 100% 0, 50% 100%)",
              // Turned about its own vertical axis (the triangle is symmetric), so once the flap
              // has swung over its apex points up, away from the envelope.
              transform: "rotateY(180deg)",
            }}
          />
        </span>

        {/* The wax seal sits where the flap meets the pocket and goes with the flap. */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute left-1/2 top-[49%] z-[5] grid aspect-square w-[15%] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full font-serif text-[clamp(0.8rem,2.4vw,1.35rem)] font-semibold text-white/90 shadow-[0_3px_8px_rgb(0_0_0/0.4)] transition-[opacity,transform] duration-300 motion-reduce:transition-none"
          style={{
            background: "radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--env-seal), #fff 35%), var(--env-seal) 55%, color-mix(in srgb, var(--env-seal), #000 35%))",
            opacity: open ? 0 : 1,
            transform: `translate(-50%, -50%) scale(${open ? 0.7 : 1})`,
          }}
        >
          {seal}
        </span>

        {/* The whole envelope is the button. */}
        <button
          type="button"
          onClick={toggle}
          aria-expanded={open}
          aria-controls={`${id}-card`}
          className="absolute inset-0 z-[6] cursor-pointer rounded-[6px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
        >
          <span className="sr-only">{open ? closeLabel : openLabel}</span>
        </button>
      </div>
    </div>
  );
}
components/vibebox/envelope/index.tsx
/**
 * VibeBox Motion · Envelope
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { Envelope, type EnvelopeProps } from "./envelope.client";

Props

PropTypeDefaultDescription
children *ReactNode—What is on the card.
colors{ paper?: string; inside?: string; card?: string; seal?: string }—The envelope's paper, the inside of the flap, the card and the wax seal.
sealstring"V"A short mark on the wax seal.
defaultOpenbooleanfalseStarts opened.
onToggle(open: boolean) => void—Called after it opens or closes.
openLabelstring"Open the envelope"The button's name while it is closed.
closeLabelstring"Close the envelope"The button's name while it is open.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
Focusable button; Enter or Space opens and closes it.
Screen readers
A button that says whether it will open or close the envelope (aria-expanded); the card's content is always readable.
Touch
Tap to open and tap to close.
Reduced motion
It opens and closes at once, with no swing or slide.

Edge cases and limits

  • The card is about two thirds of the envelope's height: keep the content short.
  • Opened, the card rises above the envelope: leave room above it.
  • The wax seal is decoration; do not put information on it.
  • A dark membership card with a spectrum holographic foil and a glare following the pointer

    A holographic card: it leans to the pointer, a glare follows, and a band of spectrum foil slides with the angle.

    3D · CSS

  • A fan of five colourful cards with the middle one lifted

    A hand of cards: lift one on hover, bring it forward on tap, arrow keys between them. Pure CSS 3D.

    3D · CSS

  • A cube of coloured cards turning, with the next face coming to the front

    Content on the faces of a turning prism: arrows, keys and drag, a face at a time. Pure CSS 3D.

    3D · CSS

↑ ↓ to move↵ to openesc to close