Postage Stamp Frame
A postage-stamp frame React component whose scalloped perforations are computed with a pure CSS clip-path; wraps an image or any content, with a caption row and an optional SVG postmark overlay that fades in on hover.
Wrap any content in a postage stamp. The scalloped perforations on all four edges come from a single clip-path: polygon() — each notch approximates a semicircle with 7 points, positions use percentages and depth uses px, so nothing needs recomputing on resize. The paper gets a 145° white highlight and a two-layer shadow. A caption row for denomination and name can sit at the bottom, and an SVG postmark (double ring, text around the circle, a centered date and three wavy cancellation lines) can be stacked at the top right; by default it fades in on hover or keyboard focus. The frame itself has no animation.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add postage-stamp-frame.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<PostageStampFrame/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/postage-stamp-frame.en.jsonOr, once registries are configured in components.json, install it as @webberui/postage-stamp-frame.
Usage
import { PostageStampFrame } from "@/components/ui/postage-stamp-frame";
<div className="w-48">
<PostageStampFrame
horizontalPerforations={12}
verticalPerforations={15}
postmark={{ text: "台北 TAIPEI", date: "2026.10.25" }}
caption={
<>
<span>NT$ 12</span>
<span>雲嶺晨霧</span>
</>
}
>
<img src="/stamps/mountain.jpg" alt="晨霧中的山景" />
</PostageStampFrame>
</div>Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Stamp content; a direct img child is filled with object-cover |
horizontalPerforations | number | 12 | Number of perforations on each of the top and bottom edges |
verticalPerforations | number | 15 | Number of perforations on each of the left and right edges |
perforationDepth | number | 6 | Depth of each semicircular perforation (px) |
padding | number | 14 | Gap between the content area and the perforations (px) |
aspect | string | "4 / 5" | Stamp aspect ratio (a CSS aspect-ratio value) |
paper | string | "#fbfaf7" | Paper color; falls back to --wb-stamp-paper when omitted |
ink | string | "#262626" | Ink color for the caption and postmark; falls back to --wb-stamp-ink when omitted |
caption | ReactNode | — | A caption row at the bottom inside the perforations (denomination, name, etc.), 10px monospace |
postmark | PostageStampPostmark | boolean | — | Postmark overlay at the top right; true uses the default text "POST · 郵資已付" (POST · postage paid; the English build uses "POST · POSTAGE PAID"), an object customizes it |
postmarkOnHover | boolean | true | Whether the postmark only fades in on hover / keyboard focus; false keeps it visible |
postmarkDuration | number | 200 | Postmark fade-in duration (ms); falls back to --wb-stamp-postmark-duration when omitted |
className | string | — | Passed through to the root container |
PostageStampPostmark
| Field | Type | Default | Description |
|---|---|---|---|
text | string | — | Text arranged along the postmark ring (e.g. a place name) |
date | string | — | Date string shown in the center of the postmark |
How it works
- Perforations: the midpoint of every perforation pitch dips inward by a semicircle, and each point is written as
calc(percent ± px), so the perforation size stays fixed as the component resizes and no ResizeObserver is needed. With many perforations and a large depth, neighbouring semicircles overlap — lowerperforationDepthif that happens. - Shadow: the two
drop-shadowlayers sit on the parent of theclip-pathelement; otherwise the shadow would be clipped away too. - The paper color stays the same in dark mode (the stamp itself is a piece of paper), and content text inherits the ink color by default.
- Theme variables:
--wb-stamp-paper,--wb-stamp-ink,--wb-stamp-postmark-duration; when the matching prop is passed, the prop wins. - The postmark sits outside the clipped frame and is printed onto the paper with
mix-blend-multiplyat 75% opacity, rotated −12°, so it may overhang the stamp edge.
Accessibility
- The frame is a purely decorative container with no
role; content semantics are up to you (give images analt; CSS-drawn illustrations can userole="img"with anaria-label) - The postmark SVG is
aria-hiddenand is not announced by screen readers; if the date matters, present it as text as well - Besides hover, the postmark also appears whenever any element inside the frame has keyboard focus (
focus-within) - When the user has reduce motion enabled at the system level, the postmark does not fade — it simply shows or hides