Skip to content
Corsair UI
Browse the docs

MotionNo. 107 of 145

Stamp

A rubber stamp that slams onto the page: it starts large and transparent and lands rotated with a small overshoot.

Installation

pnpm dlx shadcn@latest add @corsair-ui/stamp

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsuse-in-viewuse-entrance
npm
clsxtailwind-merge

Examples

Sold out

trigger="load" on a card; the button stamps it again.

Thursday, 20:00

Berth 2

14 of 14 aboard

Sold out

Angles and weight

rotate, from and duration. They land when they scroll into view.

PaidApprovedDraft

API

PropTypeDefault
rotate / from

Where it lands in degrees, and the scale it starts from.

number-8 / 1.6
trigger / play

As in blur-text.

"load" | "in-view" / boolean"in-view"
delay / duration

In ms.

number0 / 320

Styling

Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=stamp]]:text-primary".

  • It is an inline-block with nothing but the rotation: give it the stamp look with classes (border-2, uppercase, tracking-widest, a colour).
data-slot
  • stamp
State
  • data-statecomputed

Accessibility

  • The content stays readable to screen readers. With prefers-reduced-motion it holds still and shows the finished state.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")
Motion
Respects prefers-reduced-motion

Source

1 file, installed as source you own
stamp.tsxView on GitHub
"use client";

import { useMemo, type ComponentProps, type CSSProperties, type Ref } from "react";

import { useEntrance, type EntranceTrigger } from "@/registry/default/hooks/use-entrance";
import { cn } from "@/registry/default/lib/utils";

function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return (node: T | null) => {
    for (const ref of refs) {
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    }
  };
}

interface StampProps extends Omit<ComponentProps<"span">, "onAnimationEnd"> {
  as?: "span" | "div";
  /** Angle the stamp lands at, in degrees. */
  rotate?: number;
  /** Scale the stamp starts from before it slams down. */
  from?: number;
  /** "load" plays on first paint with CSS alone; "in-view" when the stamp scrolls into view. */
  trigger?: EntranceTrigger;
  /** In-view: play the first time only. */
  once?: boolean;
  /** Takes over from `trigger`: `false` holds the stamp up, `true` slams it down. */
  play?: boolean;
  /** Wait before the stamp falls, in ms. */
  delay?: number;
  /** How long the slam takes, in ms. */
  duration?: number;
  /** Called once the stamp has landed. */
  onAnimationComplete?: () => void;
}

/**
 * A rubber stamp that slams onto the page: it starts large and transparent,
 * then lands rotated with a small overshoot. The content stays readable for
 * screen readers, and the server HTML and `prefers-reduced-motion` show the
 * stamp already in place.
 *
 * @example
 * <Stamp className="border-primary text-primary rounded-md border-2 px-3 py-1 font-bold tracking-widest uppercase">Sold out</Stamp>
 */
function Stamp({
  as: Tag = "span",
  rotate = -8,
  from = 1.6,
  trigger = "in-view",
  once = true,
  play,
  delay = 0,
  duration = 320,
  onAnimationComplete,
  className,
  style,
  ref,
  ...props
}: StampProps) {
  const [observe, phase] = useEntrance<HTMLElement>({ trigger, once, play });
  const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);

  const stampStyle = {
    "--stamp-rotate": `${rotate}deg`,
    "--stamp-from": `${from}`,
    ...(phase === "static"
      ? {}
      : {
          animationDuration: `${duration}ms`,
          animationDelay: `${delay}ms`,
          animationPlayState: phase === "armed" ? "paused" : undefined,
        }),
    ...style,
  } as CSSProperties;

  return (
    <Tag
      ref={mergedRef as Ref<never>}
      data-slot="stamp"
      data-state={phase}
      className={cn(
        "inline-block [transform:rotate(var(--stamp-rotate))]",
        phase !== "static" && "motion-safe:animate-stamp",
        className
      )}
      style={stampStyle}
      onAnimationEnd={(event) => {
        if (event.target === event.currentTarget) onAnimationComplete?.();
      }}
      {...props}
    />
  );
}

export { Stamp, type StampProps };