# Liquid Glass toast

> A small glass note for “Copied” and “Saved”. Swipe up, tap or press Esc to clear it.

The confirmation message used across baked.design. It sits below your header instead of covering it, clears itself after a few seconds, pauses while you hover it, and stretches like glass when you pull it.

Real Liquid Glass: clear in the middle, with a rim that bends what's behind it and splits the colour slightly at the edge, drawn for the note's exact size every time it shows. Chrome and Edge get the full lens; Safari and Firefox get frosted glass with the same rim and light.

## Install

Copy these files into your project:

### toast.js

```js
// Liquid Glass toast by Baked Design Studio (baked.design/interfaces/liquid-glass-toast). Free to use.
// A small glass note for confirmations ("Copied", "Saved"). It sits below anything marked data-toast-avoid
// (your header), clears itself, pauses while hovered, and can be tapped, swiped up or Esc-ed away.
// Usage: import { toast } from "./toast.js"; toast("Copied");   Styles: toast.css
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
let el = null, timer = 0, left = 0, started = 0;

// Liquid Glass. The glass is a lens with a rounded rim: clear in the middle, bending what's behind it
// strongly near the edge, with a faint colour split there (red bends most, blue least), and a thin bright
// rim. The bend comes from a displacement map drawn for the note's exact size: each pixel in the rim band
// points inward, harder the closer it is to the edge, following a convex (quarter-circle) surface.
// Browsers that can use an SVG filter in backdrop-filter (Chrome, Edge) get the real refraction; the
// others get frosted glass with the same rim and light.
const canRefract = () => !!navigator.userAgentData && /Chrom/.test(navigator.userAgent);
function lensMap(w, h, r, bezel) {
  const c = document.createElement("canvas"); c.width = w; c.height = h;
  const g = c.getContext("2d"), img = g.createImageData(w, h), d = img.data;
  const hx = w / 2, hy = h / 2;
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
    // signed distance to the rounded rectangle, and its outward normal
    const px = x + 0.5 - hx, py = y + 0.5 - hy, qx = Math.abs(px) - (hx - r), qy = Math.abs(py) - (hy - r);
    let nx, ny, sd;
    if (qx > 0 && qy > 0) { const l = Math.hypot(qx, qy); sd = l - r; nx = qx / l; ny = qy / l; }
    else if (qx > qy) { sd = qx - r; nx = 1; ny = 0; } else { sd = qy - r; nx = 0; ny = 1; }
    nx *= Math.sign(px) || 1; ny *= Math.sign(py) || 1;
    const t = Math.min(1, Math.max(0, -sd / bezel));          // 0 at the edge, 1 where the flat middle starts
    const m = t >= 1 ? 0 : Math.pow(1 - t, 2.2) / Math.sqrt(1 - (1 - t) * (1 - t) + 0.12); // the slope of a convex rim
    const k = Math.min(1, m), i = (y * w + x) * 4;
    d[i] = 128 - nx * 127 * k; d[i + 1] = 128 - ny * 127 * k; d[i + 2] = 128; d[i + 3] = 255;
  }
  g.putImageData(img, 0, 0);
  return c.toDataURL();
}
const NS = "http://www.w3.org/2000/svg";
// One filter per id, redrawn for a new size. Returns false where refraction isn't available.
export function lens(id, w, h, r, { bezel = Math.min(14, h * 0.42), shift = 6, force = false } = {}) {
  if (!force && !canRefract()) return false;
  w = Math.max(2, Math.round(w)); h = Math.max(2, Math.round(h));
  let svg = document.getElementById(id + "-svg");
  if (!svg) { svg = document.createElementNS(NS, "svg"); svg.id = id + "-svg"; svg.setAttribute("aria-hidden", "true"); svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); svg.style.position = "absolute"; document.body.append(svg); }
  const S = shift * 2, ch = (n, sc) => `<feDisplacementMap in="SourceGraphic" in2="map" scale="${sc}" xChannelSelector="R" yChannelSelector="G" result="d${n}"/>`;
  svg.innerHTML = `<filter id="${id}" x="0" y="0" width="100%" height="100%" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB">
    <feImage href="${lensMap(w, h, r, bezel)}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="none" result="map"/>
    ${ch("r", S)}<feColorMatrix in="dr" type="matrix" values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0" result="r"/>
    ${ch("g", S * 0.93)}<feColorMatrix in="dg" type="matrix" values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0" result="g"/>
    ${ch("b", S * 0.86)}<feColorMatrix in="db" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0" result="b"/>
    <feBlend in="r" in2="g" mode="screen" result="rg"/><feBlend in="rg" in2="b" mode="screen"/></filter>`;
  return true;
}
// fit the note's lens to its current size (its text changes)
function refit() { if (lens("lg-refract", el.offsetWidth, el.offsetHeight, 16)) el.classList.add("refract"); }

// Made once, up front, so screen readers already know the live region when the first message lands.
function note() {
  if (el) return el;
  el = document.createElement("div");
  el.className = "toast-note";
  el.setAttribute("role", "status");
  el.setAttribute("aria-live", "polite");
  document.body.append(el);

  // swipe up to clear (a downward pull just stretches a little and springs back); a tap clears too
  let y0 = null, dy = 0, t0 = 0;
  el.addEventListener("pointerdown", (e) => { y0 = e.clientY; dy = 0; t0 = performance.now(); el.setPointerCapture(e.pointerId); el.classList.add("held"); pause(); });
  el.addEventListener("pointermove", (e) => {
    if (y0 === null) return;
    dy = e.clientY - y0;
    el.style.translate = `-50% ${dy < 0 ? dy : dy * 0.25}px`;
    const k = Math.min(0.07, Math.abs(dy) / 500); // the glass stretches as it's pulled
    el.style.scale = `${1 - k} ${1 + k}`;
  });
  const up = () => {
    if (y0 === null) return;
    const fast = dy < 0 && -dy / (performance.now() - t0) > 0.4;
    el.classList.remove("held"); y0 = null; el.style.translate = el.style.scale = "";
    if (Math.abs(dy) < 6 || dy < -24 || fast) hide(dy < -6 ? "up" : "");
    else resume();
  };
  el.addEventListener("pointerup", up);
  el.addEventListener("pointercancel", () => { y0 = null; el.classList.remove("held"); el.style.translate = el.style.scale = ""; resume(); });
  // hovering keeps it up for as long as you're reading
  el.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") pause(); });
  el.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && y0 === null) resume(); });
  addEventListener("keydown", (e) => { if (e.key === "Escape" && el.classList.contains("on")) hide(); });
  return el;
}

// below your header (anything with data-toast-avoid), whichever is lowest and still on screen
function place() {
  let top = 12;
  for (const b of document.querySelectorAll("[data-toast-avoid]")) {
    const r = b.getBoundingClientRect();
    if (r.height && r.bottom > 0 && getComputedStyle(b).display !== "none") top = Math.max(top, r.bottom + 10);
  }
  el.style.top = Math.min(top, innerHeight * 0.4) + "px";
}

function pause() { if (!timer) return; clearTimeout(timer); timer = 0; left = Math.max(1200, left - (performance.now() - started)); }
function resume() { if (timer || !el.classList.contains("on")) return; started = performance.now(); timer = setTimeout(() => hide(), left); }

function hide(dir = "") {
  clearTimeout(timer); timer = 0;
  if (!el) return;
  el.classList.toggle("away-up", dir === "up" && !reduce());
  el.classList.remove("on");
}

export function toast(text) {
  note();
  clearTimeout(timer); timer = 0;
  const again = el.classList.contains("on");
  el.textContent = text;
  el.classList.remove("away-up");
  place();
  refit();
  el.classList.add("on");
  if (again && !reduce()) el.animate([{ scale: "1.04 .96" }, { scale: "1" }], { duration: 380, easing: "cubic-bezier(.3,1.6,.5,1)" }); // a wobble when the text changes
  left = Math.min(6000, 2600 + text.length * 35);
  started = performance.now(); timer = setTimeout(() => hide(), left);
}

if (document.body) note(); else addEventListener("DOMContentLoaded", note);
```

### toast.css

```css
/* Liquid Glass toast by Baked Design Studio (baked.design/interfaces/liquid-glass-toast). Free to use.
   Set --toast-bg and --toast-ink to your page and text colours. */
:root{ --toast-bg:#fdfdfc; --toast-ink:#111; }
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --toast-bg:#111112; --toast-ink:#ededea; } }
.toast-note{ --tint:color-mix(in srgb, var(--toast-bg) 30%, transparent); --spec:rgba(255,255,255,.9); --rim:rgba(255,255,255,.5);
  position:fixed; left:50%; top:12px; z-index:90; translate:-50% -8px; scale:.94; box-sizing:border-box; width:max-content; max-width:min(360px, calc(100vw - 32px));
  padding:10px 16px; border-radius:16px; font-size:13px; line-height:18px; font-weight:500; color:var(--toast-ink); text-align:left; isolation:isolate;
  text-shadow:0 0 6px color-mix(in srgb, var(--toast-bg) 80%, transparent), 0 1px 1px color-mix(in srgb, var(--toast-bg) 60%, transparent);
  /* frosted where the browser can't refract */
  background:var(--tint); -webkit-backdrop-filter:blur(8px) saturate(1.8) brightness(1.06); backdrop-filter:blur(8px) saturate(1.8) brightness(1.06);
  /* the rim: a thin bright edge, brightest at the top left where the light hits, a softer bounce at the bottom right */
  box-shadow:inset 0 0 0 .5px var(--rim), inset 1.5px 1.5px 1px -1px var(--spec), inset -1px -1px 1px -.5px rgba(255,255,255,.35),
    0 0 0 .5px rgba(0,0,0,.08), 0 8px 24px -10px rgba(0,0,0,.28);
  opacity:0; pointer-events:none; touch-action:none; user-select:none; cursor:default;
  transition:opacity .22s ease, translate .45s cubic-bezier(.3,1.5,.5,1), scale .45s cubic-bezier(.3,1.5,.5,1); }
/* the sheen: light pooling along the top edge, fading before the middle so the glass stays clear */
.toast-note::before{ content:""; position:absolute; inset:0; z-index:-1; border-radius:inherit; pointer-events:none;
  background:linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,0) 42%), radial-gradient(60% 80% at 100% 120%, rgba(255,255,255,.14), transparent 70%); }
/* real Liquid Glass (Chrome, Edge): clear in the middle, bending and splitting colour at the rim */
.toast-note.refract{ --tint:color-mix(in srgb, var(--toast-bg) 14%, transparent); -webkit-backdrop-filter:url(#lg-refract) blur(.6px) saturate(1.5) brightness(1.08); backdrop-filter:url(#lg-refract) blur(.6px) saturate(1.5) brightness(1.08); }
.toast-note.on{ opacity:1; translate:-50% 0; scale:1; pointer-events:auto; }
.toast-note.held{ transition:scale .2s cubic-bezier(.3,1.5,.5,1); scale:.97; }
.toast-note.away-up{ translate:-50% -40px; scale:.9; transition:opacity .2s ease, translate .24s ease-in, scale .24s ease-in; }
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]) .toast-note{ --tint:color-mix(in srgb, var(--toast-bg) 40%, transparent); --spec:rgba(255,255,255,.45); --rim:rgba(255,255,255,.22); }
  :root:not([data-theme="light"]) .toast-note.refract{ --tint:color-mix(in srgb, var(--toast-bg) 26%, transparent); }
  :root:not([data-theme="light"]) .toast-note::before{ opacity:.45; } }
:root[data-theme="dark"] .toast-note{ --tint:color-mix(in srgb, var(--toast-bg) 40%, transparent); --spec:rgba(255,255,255,.45); --rim:rgba(255,255,255,.22); }
:root[data-theme="dark"] .toast-note.refract{ --tint:color-mix(in srgb, var(--toast-bg) 26%, transparent); }
:root[data-theme="dark"] .toast-note::before{ opacity:.45; }
@media (prefers-reduced-motion: reduce){ .toast-note, .toast-note.away-up{ transition:opacity .2s ease; } }
```

## Use

```js
import { toast } from "./toast.js";

toast("Copied");
```

## Notes

- Add data-toast-avoid to your header (or any bar fixed to the top) and the toast appears just below it.
- Set --toast-bg and --toast-ink in toast.css to your page and text colours. Dark mode is included.
- Screen readers hear every message: the note is a polite live region.
- No dependencies. Plain JavaScript and CSS.

By Baked Design Studio: https://baked.design/interfaces/
