caustic
v0.1.0-beta.1
Published
Physically-based liquid glass for the web. Real refraction from Snell's law, rendered in a shader. Zero dependencies.
Maintainers
Readme
caustic
Physically-based liquid glass for the web. Refraction computed per pixel from Snell's law against a real height profile, rendered in a shader.
Not a blur. A blur scatters light, which is what frost does; a lens bends it, so edges behind it compress toward the rim, straight lines curve, and the highlight travels as the surface turns. No blur radius produces that at any strength.
Zero runtime dependencies. ESM. MIT.
npm install caustic@betaBeta. The optics and the shader are settled and covered by tests; the control
props are newer and may still move before 1.0. The @beta tag is required:
nothing is published to latest yet, so a bare npm install caustic will not
resolve.
The one thing to know first
Glass has to be handed the picture it bends.
A shader samples a texture, and the document's own pixels are not one. There is no API that hands you the rendered page as an image, and there should not be, since that would be a read of everything on a user's screen. So a pane cannot see what is behind it in the DOM sense, and no amount of API design changes that.
Every component here therefore takes a backdrop, paints one, or sits on a surface that does. It reads like a worse API than floating over a backdrop and it is the opposite: the same information written down instead of guessed at.
import { Glass } from "caustic/react";
<Glass backdrop="/photo.jpg" radius={28} style={{ width: 420, height: 240 }}>
<h2>Anything you like</h2>
</Glass>;backdrop is refracted. children are not: they sit on the surface,
unrefracted, which is the only reason a label on glass is readable.
Which component
| | |
|---|---|
| <Glass> | A pane with your own content on it. The general case. |
| <Loupe> | A circular lens you drag across a surface. |
| <Switch> <Slider> <ColorSlider> <Button> <Toolbar> | Finished controls, one material. |
| <Blob> | A fixed two-lobed shape, for looking at. |
| <MediaSurface> + <MediaGlass> | The primitive underneath all of them. |
<Glass> or a surface
The rule that decides it:
Use <Glass> when the glass is the whole box. Use a <MediaSurface> when
the glass sits on something larger.
<Glass> owns its backdrop and covers the picture to its own box. That is right
for a card that fills its own frame. It is wrong for a button on a photograph: a
168×52 pill cover-cropping a landscape shows a zoomed slice unrelated to the
picture behind it. A pane that is small against its backdrop needs the backdrop
to be a shared thing it is a window into, which is what a surface is.
import { MediaSurface, MediaGlass } from "caustic/react";
const photo = useRef<HTMLImageElement>(null);
<MediaSurface source={photo} objectFit="cover" live={false}>
<img ref={photo} src="/photo.jpg" alt="" style={{ display: "block", width: "100%" }} />
<MediaGlass radius="pill" style={{ width: 168, height: 52 }}>Continue</MediaGlass>
</MediaSurface>;One surface is one WebGL context, and a browser keeps roughly eight to sixteen before silently dropping the oldest. Put many panes on one surface rather than giving each its own.
The rule the library is arranged around
Moving a pane must never regenerate its displacement map.
The map is a function of the pane's shape, not of where it is, so a move costs
a viewport and a uniform. That is what makes <Loupe> draggable at 60fps.
Optics changes take the expensive path and rebuild the map; position changes
never do.
Four things that will cost you an afternoon
Each of these renders something plausible and wrong, and none of them errors.
Keep the surface's source in normal flow. The panes are drawn onto the
surface's own absolutely positioned canvas. An absolutely positioned <img> or
<canvas> source paints above that canvas and hides every pane on it. The
glass renders perfectly and you see none of it, which looks exactly like optics
that do not work.
Never transform an element that owns a pane. The glass is drawn into the
surface's canvas at the rectangle the pane reports, so a CSS scale moves the
label and the border and leaves the refraction where it was: the control comes
apart into two pieces. Deform the pane through PanePlacement.squash instead.
Draw one rim, not two. MediaGlass lights its own edge from the rim term.
An inset box-shadow on top of a pane that already has a strong rim puts a
second contour a pixel outside the first, which reads as two layers of glass.
Use one or the other.
A lens only shows what it bends. A pane over a flat sky or a wall of uniform forest refracts correctly and renders as an empty rectangle. Aim it at something with an edge in it before concluding the optics are broken.
Tiers
Three, and the library tells you which one a page is on. Only the first renders today; the other two are detected and warned about, and nothing yet paints them.
| Tier | Condition | Result |
|---|---|---|
| 1 | WebGL | refraction, specular, rim |
| 2 | prefers-reduced-transparency | opaque fill, no blur |
| 3 | no WebGL, or a tainted source | backdrop-filter: blur(), rim |
Requirements
WebGL. React 18 or 19 for caustic/react; the core entry pulls in no React at
all, and nothing under its maths or map directories touches the DOM, so the
optics can be tested without a browser.
Development warnings
In development the library warns about three failures that produce no error of their own: a surface whose source is positioned over the panes and hides them, a pane larger than the displacement map generated for it, and a page running below the top tier. All of it compiles out of production builds.
There were three more, covering opacity flattening, bordered clips and video
under a CSS filter. Every one of them described a way the filter backend broke,
and they were removed with it rather than left to warn about a code path that no
longer exists.
