Watermark

A repeating mark of text or an image drawn over its children, to discourage sharing and trace a leaked screenshot.

Usage

When to reach for Watermark, what each of its props is for, and what it does not protect against.

When to use

Watermark stamps a repeating mark across a region: a line of text or an image, tiled over the content you wrap. The content stays usable. The marks do not block clicks, text selection or focus.
Use it to trace a leak, not to prevent one. The content it sits over is already on the client, fully rendered. Anyone who can photograph the screen, turn JavaScript off or run one line in the console reads it unmarked. The mark makes a leaked screenshot say who was looking at it, which is a real job and a different one from prevention.
Reach for it when:
  • A reader is allowed to see something but not to redistribute it: a contract preview, an unreleased report, a customer record.
  • The screen should carry its own classification, and that classification should survive being screenshotted out of context.
  • A trial or preview tier should stay usable while visibly marked. The mark names the plan instead of blocking the content.
Do not reach for it when:
  • The reader must not see the content at all. That decision belongs on the server, and the answer is not to send the bytes.
  • The mark is where the information lives. The marks are a background image with no text, so they reach no screen reader and survive no copy and paste.
  • The region has no height of its own. Content that has collapsed to zero height shows no marks.

What the mark says

content takes a string, a WatermarkText object carrying its own font, or an array of either.
  • An array stacks its entries inside one tile rather than producing separate marks. That is the shape you want for a classification above an identifier.
  • Two lines is usually the whole design: content={[classification, currentUser.email]}. What this is, and who was looking at it.
  • Keep content beside image. image wins when both are set, and content shows instead if the image fails to load. The image is loaded with crossOrigin="anonymous", so its server has to allow cross-origin requests.
  • An image tile defaults to 120 by 64. Supply the asset at twice that: the tile is drawn at the device pixel ratio, so a 1x source is visibly soft on a retina screen.
  • With neither image nor content, nothing is drawn.

Ink, angle and rhythm

  • font is the ink: color, fontSize, fontWeight, fontStyle, fontFamily, textAlign. The defaults are token.colorFill and token.fontSizeLG over sans-serif, so passing no font at all already follows the theme in both modes.
  • The loudness is the alpha in font.color. Turning it up makes the text underneath harder to read for everyone, which is the usual failure here, not a mark that is too faint.
  • rotate is degrees and defaults to -22. Level text is easier to read but reads as a header, and it is easier to crop out of a screenshot. Past about 45 degrees the lines start crowding the neighboring tiles.
  • gap is an [x, y] pair in pixels, default [100, 100]. offset takes the same shape and defaults to half the gap. That staggers the rows into a brick pattern, and offset={[0, 0]} lines the marks up in a plain grid.
  • width and height override the tile size. Set them for an image. Leave them alone for text, where the tile is sized to fit the lines.

Stacking, and the element it wraps

  • zIndex defaults to token.zIndexPopupBase - 1, so the mark sits above the page content and one step below the base popup layer. A Dropdown, a Tooltip or a Select panel opens over it, which is what you want.
  • Raising zIndex starts covering things that are meant to be on top. Lowering it lets a positioned child cover the mark. Move it only when a specific layer is wrong, and check both directions.
  • Watermark sets position: relative and overflow: hidden on its element and puts them back if something changes them. A layout that needs that element to overflow needs a different wrapper. Watermark also clips what it wraps, so pad it when the child has a shadow.
  • Give the wrapped content a height. The marks cover the Watermark's own box, so content that has collapsed to zero height shows no marks. Wrap the thing with a height (a Card, a fixed-height preview), not a fragment that collapses.

Nested marks and portals

A Modal or a Drawer renders through a portal, outside the element you wrapped, so the mark cannot reach it through the page. inherit (default true) is what carries it anyway.
  • The marks travel through React context, and Modal and Drawer pick them up from it. A popup opened from anywhere inside the subtree is marked however deep it sits.
  • inherit={false} keeps a popup clean: that Watermark marks its own element and nothing portalled out of it.
  • A nested Watermark with inherit={false} hands its descendants back to whichever Watermark is above it, so a popup opened underneath it picks up the outer mark rather than the inner one.
  • Nested Watermarks each draw their own marks over their own region. Two marks reading across the same area is a bug the reader can see, so nest them only where the regions do not overlap.

It is a raster, not CSS

The tile is drawn to a <canvas> and shown as a background image. Nothing about the mark is CSS, so no Tailwind class and no cascade reaches it.
  • Pass a resolved color. The mark is drawn on a canvas, so a CSS variable in font.color, such as var(--ant-color-text-quaternary), paints solid black.
  • Read a theme color from theme.useToken() instead. It returns a real color string, and because it is a hook the mark repaints on a theme change.
  • Or pass no color at all. The default is token.colorFill, so the mark already follows light and dark mode. A literal tuned by eye for one mode stays that bright, or that invisible, in the other.
  • The tile is drawn at window.devicePixelRatio, which keeps it crisp on a retina screen. The ratio is read when the tile is drawn, so dragging the window to a monitor with a different ratio does not redraw it. A prop change does.
  • Printing is the gap people forget. The mark is a background image, and browsers leave background graphics out of a print by default, so a page printed with the default settings can come out unmarked. If the printed copy has to carry the classification, put it in real text on the page as well.

Removal, and its limits

Watermark watches its element, and every popup it reaches through inherit, for someone removing or editing the marks.
  • Removing the marks or editing their attributes triggers a redraw. Editing the container's style puts position and overflow back.
  • The marks' element has no class, drops a hidden attribute and forces visibility: visible, so a devtools “hide element” or a user stylesheet does not hide it.
  • onRemove fires when a removed mark is put back. Log from it if you want a record of the attempt.
  • What it stops: right-click, inspect, delete node. What it does not stop: a screenshot, a photo of the screen, JavaScript disabled, a client that never runs the script, a print with background graphics off, or anyone willing to type one line into the console.
  • Do not rely on the redraw. A removed mark is not always put back straight away.