React component for shadcn/ui
Ripple Reveal
A React show and hide control for shadcn/ui that hides a balance or other sensitive value behind a ripple starting exactly where you tap, drawn by a WebGL shader over live DOM, with a soft tap, a light haptic, and a reduced motion fallback.
Example
Features
- A show and hide control for a balance, a card number, or a salary. The ripple starts at the exact point you tap, like a pebble dropped in a pond, and the content flips between its two faces as the wave front passes.
- One WebGL fragment shader paints the wave as light: a bright crest just behind the front, a soft trough after it, and a faint second swell. Tap again mid-ripple and a second wave crosses the first.
- The content stays live DOM. The value is real text that keeps updating and can be selected, and it is never copied into a bitmap.
- Both faces share one grid cell, so the surface is as big as the larger of the two and toggling never shifts the layout.
- One soft tap and one light haptic per press, timed to the start of the wave. At rest nothing runs, draws, or holds a GPU layer.
- With reduced motion, or without WebGL, the faces cross-fade over 140 ms instead, with the same state, sound, and accessibility.
Installation
pnpm dlx shadcn@latest add @tentui/ripple-reveal
Usage
import {
RippleReveal,
RippleRevealSurface,
RippleRevealTrigger,
} from "@/components/ripple-reveal";RippleReveal holds the state. RippleRevealSurface is the part that
ripples: its children are the shown face. RippleRevealTrigger is the button.
Arrange them however you like. For an inline value, the default hidden face is
a row of five dots in the current text color:
<RippleReveal className="flex items-center gap-1">
<RippleRevealSurface className="font-semibold tabular-nums">
$8,240.15
</RippleRevealSurface>
<RippleRevealTrigger label="Show available balance" />
</RippleReveal>For a card, give the surface a size and pass your own hidden face as
placeholder. Put the background on each face, and pin the trigger on top of
the surface:
<RippleReveal className="relative w-full max-w-[340px]">
<RippleRevealSurface
className="grid h-52 rounded-3xl"
hiddenLabel="Balance hidden"
placeholder={<HiddenCard />}
>
<BalanceCard />
</RippleRevealSurface>
<RippleRevealTrigger
label="Show balance"
className="absolute top-3 right-3"
/>
</RippleReveal>Use revealed and onRevealedChange to control it. onRevealedChange fires
the moment the trigger is pressed, not when the wave has finished. A change
that comes from the parent instead ripples silently from the center. Sound is
one prop away, and turning it off is a reasonable choice in a banking UI:
const [shown, setShown] = useState(false);
<RippleReveal revealed={shown} onRevealedChange={setShown} sound={false}>
<RippleRevealSurface hiddenLabel="Salary hidden">$6,450.00</RippleRevealSurface>
<RippleRevealTrigger label="Show salary" />
</RippleReveal>The light is tuned for a surface's theme with two custom properties, read when
each ripple starts: --ripple-reveal-highlight (white, 0.6 in light mode and
0.4 in dark) and --ripple-reveal-shade (black, 0.12 and 0.25). A dark card
on a light page can raise both.
How it works
The wave is four lines of maths. For each front, local is how long ago the
front reached a pixel, swell is a damped sine of that, several fronts add
up, and every front that has passed a pixel flips it between the two faces:
float local = max(0.0, age - distance / speed);
float swell = sin(frequency * local) * exp(-decay * local);
displacement += amplitude * swell * direction;
passed = passed + inside - 2.0 * passed * inside;On a phone, the card is a GPU texture and a shader can bend its pixels. On the web the content is live DOM, and there is no dependable way to get arbitrary DOM into a WebGL texture. Snapshots mis-render fonts, break on cross-origin images, are asynchronous, pop when swapped back, and would copy the sensitive value into a bitmap. So the component never rasterizes the DOM:
- The flip is CSS masks. Each front is a
radial-gradientcircle centered on its tap point, growing at the wave's speed. The circles are combined withmask-composite: exclude, which is the XOR on the last line above. - The wave is light. A fragment shader on a transparent canvas evaluates the same damped sine, takes its slope as a surface normal, and paints a specular crest, a soft lift on the rising side, a shade in the trough, and a hairline glint on the front. Flat water draws nothing.
- The text swells. While a front crosses, the face inside it scales up by up to 2 percent about the tap point and the face outside scales down a little, so real text steps like a lens at the front and settles back to exactly 1.
One requestAnimationFrame clock sets the shader, the masks, and the scale
together, so they never drift apart. Every surface on the page shares one
WebGL context and copies its frame into its own canvas, so a list of a dozen
values never runs out of contexts. The shader is compiled the first time a
pointer or focus reaches any trigger, so the first ripple isn't late.
The front crosses the surface in about 0.4 to 0.55 s, slower for a small inline value so it is still seen, and the surface is at rest by about 0.8 s. Hiding is the same wave at 85 percent strength.
Fallback
With prefers-reduced-motion: reduce, when WebGL is unavailable, or after the
context is lost, the two faces cross-fade over 140 ms instead: no canvas, no
masks, no scale. Reduced motion is checked on every press, not once at mount.
State, sound, haptics, and accessibility are the same either way. A ripple
that is cut short, by hiding the tab, losing the context, or collapsing the
surface, lands in its final state at once.
Sound and haptics
Each press plays one very soft, low tap, like a fingertip on a taut surface, with a light haptic. Revealing is a shade higher than hiding. There is no water sound: this is a privacy gesture people make in public, and it shouldn't announce itself.
Sound and haptics only follow a press of the trigger. Mounting, changes to
the revealed prop, and a hidden tab are silent. Both stay on under reduced
motion. Turn each off with sound={false} and haptics={false}.
Accessibility
The trigger is a button with aria-pressed set to the revealed state. Its
name, from label, aria-label, or aria-labelledby, doesn't change with
the state, so "Show balance" reads as pressed or not pressed. Space and
Enter work, and start the wave from the center of the trigger. The focus
ring shows for keyboard focus only, and the hit area is at least 44 by 44 px
even when the visible button is smaller. A disabled root dims the trigger and
ignores it.
From the moment the value is hidden, it is aria-hidden and inert, and a
visually hidden hiddenLabel takes its place. Once the wave settles it is
also visibility: hidden, so find in page and select all can't reach it. The
placeholder is always hidden from assistive technology. After each press, a
polite status region announces valueText (or the value's own text) on
reveal and hiddenLabel on hide. Nothing is announced on mount.
This is a visual privacy control, not a security boundary. The value is still in the DOM, and anyone with the developer tools can read it. Don't render a value the user isn't allowed to see.
API reference
RippleReveal
RippleRevealSurface
RippleRevealTrigger
FAQ
How do I install the Ripple Reveal with shadcn?
Run npx shadcn@latest add @tentui/ripple-reveal after adding the @tentui
registry to components.json (installation guide), or run
npx shadcn@latest add https://tentui.com/r/ripple-reveal.json without it. The
CLI copies ripple-reveal.tsx into your project and installs web-haptics and
@rexa-developer/tiks.
Is the Ripple Reveal accessible to screen readers and keyboards?
Yes. The trigger is a real button with aria-pressed, and Space or Enter
start the wave from its center. While hidden, the value is aria-hidden and
inert and a hiddenLabel takes its place, and a polite status region
announces the value on reveal and the hidden label on hide.
How do I control whether the value is shown?
Pass revealed and onRevealedChange, for example from a user preference you
store. onRevealedChange fires the moment the trigger is pressed, and a change
that comes from the revealed prop ripples silently from the center. Use
defaultRevealed (true by default) when you don't need to control it.
Does it work without WebGL or with reduced motion?
Yes. With prefers-reduced-motion: reduce, without WebGL, or after the WebGL
context is lost, the two faces cross-fade over 140 ms instead. State, sound,
haptics, and accessibility stay the same.
Can I customize the hidden face?
Yes. Pass any React content as placeholder to replace the default row of five
dots, style each face with your own classes, and tune the light with the
--ripple-reveal-highlight and --ripple-reveal-shade custom properties. Turn
off the cues with sound={false} and haptics={false}.
Is the Ripple Reveal free to use?
Yes. The Ripple Reveal is free and source-available: the shadcn CLI copies its source into your project, and you can use it in unlimited personal and client projects under the TentUI License. The license doesn't allow redistributing or reselling the component itself.