Skip to content

Glass Focus Field

A liquid-glass input that shows focus by thickening its glass outward from where you clicked, with a floating label and a warm-tinted error state.

FreeGlassmorphismglassminimal
Category
Patterns
Added
2026-09-26
Deps
none
Updated
2026-09-26
Preview

Get early access

One email when your invite is ready. Nothing else.

Work or personal, either is fine.

Props

Message field
1
No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, PointerEvent as ReactPointerEvent } from "react";

/**
 * GlassField — a text field that shows focus by thickening its glass.
 *
 * At rest the field is thin, nearly clear glass. Focus it and a second, deeper
 * pane blooms outward from the point you clicked (or from where the text
 * starts, when you arrive by keyboard): more blur, more saturation, a brighter
 * rim, and in Chromium a refracting edge. The glass gets thicker where your
 * attention is, instead of a ring being drawn around it. Leaving the field
 * drains the pane back to the same point.
 *
 * The label floats up out of the way on focus or once there is a value.
 * `error` tints the glass warm and announces the message; `hint` sits below.
 * Works as a single-line field (a full pill, the kit's control shape) or,
 * with `multiline`, a textarea with 24px corners.
 *
 * The two panes are siblings rather than nested, so each one blurs the page
 * behind it rather than the other pane. The refracting rim runs only in
 * Chromium (detected, not guessed with @supports: Safari and Firefox parse
 * backdrop-filter: url() and draw nothing); elsewhere the bloom is blur,
 * saturation and rim alone.
 *
 * Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
 */

type Tone = "dark" | "light";

/** "#rrggbb" to "r,g,b", or null. Anything else is ignored, never interpolated. */
function rgbOf(hex?: string): string | null {
  const m = /^#([0-9a-f]{6})$/i.exec(hex || "");
  if (!m) return null;
  const n = parseInt(m[1], 16);
  return ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255);
}

/** Displacement map for a rounded rectangle: red = x, green = y, 128 = none. */
function lensMap(w: number, h: number, r: number, bezel: number): string {
  const c = document.createElement("canvas");
  c.width = w;
  c.height = h;
  const ctx = c.getContext("2d");
  if (!ctx) return "";
  const img = ctx.createImageData(w, h);
  const hw = w / 2;
  const hh = h / 2;
  const rr = Math.min(r, hw, hh);
  const sd = (x: number, y: number) => {
    const qx = Math.abs(x) - (hw - rr);
    const qy = Math.abs(y) - (hh - rr);
    return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - rr;
  };
  for (let j = 0; j < h; j++) {
    for (let i = 0; i < w; i++) {
      const x = i + 0.5 - hw;
      const y = j + 0.5 - hh;
      const t = -sd(x, y);
      let dx = 0;
      let dy = 0;
      if (t > 0 && t < bezel) {
        const nx = sd(x + 0.5, y) - sd(x - 0.5, y);
        const ny = sd(x, y + 0.5) - sd(x, y - 0.5);
        const len = Math.hypot(nx, ny) || 1;
        const m = Math.pow(1 - t / bezel, 2);
        dx = (-nx / len) * m;
        dy = (-ny / len) * m;
      }
      const k = (j * w + i) * 4;
      img.data[k] = Math.round(128 + 127 * dx);
      img.data[k + 1] = Math.round(128 + 127 * dy);
      img.data[k + 2] = 128;
      img.data[k + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  return c.toDataURL();
}

export function GlassField({
  label,
  name,
  id,
  type = "text",
  value,
  defaultValue,
  onChange,
  placeholder,
  multiline = false,
  rows = 4,
  hint,
  error,
  required,
  disabled,
  autoComplete,
  tone = "dark",
  tint,
  refraction = 1,
  className = "",
}: {
  label: string;
  name?: string;
  id?: string;
  type?: "text" | "email" | "password" | "search" | "tel" | "url";
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  /** Shown only once the label has floated out of the way. */
  placeholder?: string;
  multiline?: boolean;
  rows?: number;
  hint?: string;
  /** A message here marks the field invalid and tints the glass warm. */
  error?: string;
  required?: boolean;
  disabled?: boolean;
  autoComplete?: string;
  /** Glass over dark content (white text) or light content (ink text). */
  tone?: Tone;
  /** Tint of the focused glass, "#rrggbb", at 14%. */
  tint?: string;
  /** Rim refraction of the focused glass, 0–2 (Chromium only). */
  refraction?: number;
  className?: string;
}) {
  const auto = useId().replace(/[^a-zA-Z0-9]/g, "");
  const fieldId = id || "gf" + auto;
  const filterId = "gfl" + auto;
  const shellRef = useRef<HTMLDivElement>(null);
  const thickRef = useRef<HTMLSpanElement>(null);
  const mapRef = useRef<SVGFEImageElement>(null);
  const dispRef = useRef<SVGFEDisplacementMapElement>(null);
  const origin = useRef({ x: 22, y: 0, fromPointer: false });
  const [focused, setFocused] = useState(false);
  const [typed, setTyped] = useState(!!defaultValue);
  const filled = value !== undefined ? value !== "" : typed;
  const floated = focused || filled;
  const dark = tone === "dark";
  const radius = multiline ? 24 : 999;

  const thickBase = "blur(18px) saturate(200%) brightness(" + (dark ? 1.1 : 1.05) + ")";

  // The focused pane's refracting rim, sized to the field, where it can run.
  useEffect(() => {
    const shell = shellRef.current;
    const thick = thickRef.current;
    if (!shell || !thick) return;
    const ua = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
    const chromium = !!ua && !!ua.brands && ua.brands.some((b) => /Chromium/.test(b.brand));
    if (!chromium || refraction <= 0) {
      thick.style.backdropFilter = thickBase;
      return;
    }
    let key = "";
    const apply = () => {
      const w = Math.round(shell.offsetWidth);
      const h = Math.round(shell.offsetHeight);
      if (!w || !h || !mapRef.current || !dispRef.current) return;
      const k = w + "x" + h;
      if (k !== key) {
        key = k;
        mapRef.current.setAttribute("href", lensMap(w, h, multiline ? 24 : h / 2, Math.min(h * 0.26, 14)));
        mapRef.current.setAttribute("width", String(w));
        mapRef.current.setAttribute("height", String(h));
      }
      // Peak offset (half the scale) at 0.4 of the bezel: a crisp bend, not a smear.
      dispRef.current.setAttribute("scale", String(Math.round(0.8 * Math.min(h * 0.26, 14) * refraction)));
      thick.style.backdropFilter = "url(#" + filterId + ") " + thickBase;
    };
    apply();
    const ro = new ResizeObserver(apply);
    ro.observe(shell);
    return () => ro.disconnect();
  }, [filterId, thickBase, refraction, multiline]);

  /** Grow the deep pane from the origin, or drain it back there. */
  function bloom(open: boolean) {
    const el = thickRef.current;
    const shell = shellRef.current;
    if (!el || !shell) return;
    const w = shell.offsetWidth;
    const h = shell.offsetHeight;
    const o = origin.current;
    if (open && !o.fromPointer) {
      // Arriving by keyboard: start where the text starts.
      o.x = 22;
      o.y = multiline ? 30 : h / 2;
    }
    o.fromPointer = false;
    const at = " at " + o.x.toFixed(1) + "px " + o.y.toFixed(1) + "px)";
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (open) {
      const r = Math.hypot(Math.max(o.x, w - o.x), Math.max(o.y, h - o.y)) + 2;
      el.style.transition = "none";
      el.style.clipPath = "circle(0px" + at;
      // Commit the starting circle, so the transition grows from the origin.
      el.getBoundingClientRect();
      el.style.transition = reduce ? "none" : "clip-path 300ms cubic-bezier(0.22, 1, 0.36, 1)";
      el.style.clipPath = "circle(" + r.toFixed(1) + "px" + at;
    } else {
      el.style.transition = reduce ? "none" : "clip-path 260ms cubic-bezier(0.4, 0, 1, 1)";
      el.style.clipPath = "circle(0px" + at;
    }
  }

  function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
    const shell = shellRef.current;
    if (!shell || disabled) return;
    // Layout pixels, even inside a transform-scaled container.
    const r = shell.getBoundingClientRect();
    const k = shell.offsetWidth / (r.width || 1);
    origin.current = { x: (e.clientX - r.left) * k, y: (e.clientY - r.top) * k, fromPointer: true };
  }

  function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
    if (value === undefined) setTyped(e.target.value !== "");
    onChange?.(e.target.value);
  }

  const rgb = error ? "255,69,58" : rgbOf(tint);
  const restFill = rgb ? "rgba(" + rgb + ",0.08)" : dark ? "rgba(18,18,24,0.22)" : "rgba(255,255,255,0.3)";
  const thickFill = rgb ? "rgba(" + rgb + ",0.14)" : dark ? "rgba(18,18,24,0.26)" : "rgba(255,255,255,0.5)";
  const ink = dark ? "#ffffff" : "#111111";
  // The error replaces the hint on screen, so it replaces it here too.
  const describedBy = error ? fieldId + "-error" : hint ? fieldId + "-hint" : undefined;

  const fieldCls =
    "relative z-10 block w-full resize-none bg-transparent px-5 text-[16px] leading-6 outline-none " +
    "placeholder:opacity-0 focus:placeholder:opacity-45 disabled:cursor-not-allowed " +
    "[&:-webkit-autofill]:[-webkit-text-fill-color:currentColor] [&:-webkit-autofill]:[transition:background-color_600000s_0s] " +
    (multiline ? "pb-3 pt-7" : "h-[58px] pb-2 pt-[22px]");
  const common = {
    id: fieldId,
    name,
    value,
    defaultValue,
    placeholder,
    required,
    disabled,
    autoComplete,
    "aria-invalid": error ? true : undefined,
    "aria-describedby": describedBy,
    onChange: handleChange,
    onFocus: () => {
      setFocused(true);
      bloom(true);
    },
    onBlur: () => {
      setFocused(false);
      bloom(false);
    },
    className: fieldCls,
    style: { color: ink },
  };

  return (
    <div className={"w-full " + className} style={{ opacity: disabled ? 0.5 : 1 }}>
      <div ref={shellRef} className="relative isolate" style={{ borderRadius: radius }} onPointerDown={onPointerDown}>
        {/* Thin, nearly clear glass at rest. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0"
          style={{
            borderRadius: radius,
            background: restFill,
            backdropFilter: "blur(6px) saturate(150%) brightness(1.04)",
            WebkitBackdropFilter: "blur(6px) saturate(150%) brightness(1.04)",
          }}
        />
        {/* The deep pane: it blooms from the focus origin (clip-path). */}
        <span
          ref={thickRef}
          aria-hidden
          className="pointer-events-none absolute inset-0"
          style={{
            borderRadius: radius,
            background: thickFill,
            backdropFilter: thickBase,
            WebkitBackdropFilter: thickBase,
            clipPath: "circle(0px at 22px 50%)",
          }}
        />
        {/* Rim: graduated at rest, brighter and closed all round while focused. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 transition-shadow duration-[240ms]"
          style={{
            borderRadius: radius,
            boxShadow: focused
              ? "inset 0 1px 0 rgba(255,255,255,0.9), inset 0 -1px 0 rgba(255,255,255,0.32), inset 0 0 0 1.5px rgba(255,255,255," +
                (dark ? 0.55 : 0.85) + "), 0 12px 40px rgba(0,0,0," + (dark ? 0.28 : 0.12) + ")"
              : "inset 0 1px 0 rgba(255,255,255," + (dark ? 0.45 : 0.8) + "), inset 0 -1px 0 rgba(255,255,255,0.14), 0 8px 28px rgba(0,0,0," +
                (dark ? 0.16 : 0.08) + ")",
          }}
        />
        <label
          htmlFor={fieldId}
          className="pointer-events-none absolute left-5 z-20 origin-left font-semibold tracking-[-0.01em] transition-[transform,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
          style={{
            top: multiline ? 17 : 19,
            fontSize: 15,
            lineHeight: "20px",
            color: ink,
            opacity: floated ? 0.72 : 0.62,
            transform: floated ? "translateY(-10px) scale(0.76)" : "none",
          }}
        >
          {label}
          {required && <span aria-hidden> *</span>}
        </label>
        {multiline ? <textarea rows={rows} {...common} /> : <input type={type} {...common} />}
        <svg aria-hidden width="0" height="0" style={{ position: "absolute" }}>
          <filter id={filterId} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
            <feImage ref={mapRef} x="0" y="0" preserveAspectRatio="none" result="map" />
            <feDisplacementMap ref={dispRef} in="SourceGraphic" in2="map" scale="24" xChannelSelector="R" yChannelSelector="G" />
          </filter>
        </svg>
      </div>
      <div aria-live="polite">
        {error ? (
          <p id={fieldId + "-error"} className="mt-2 flex items-center gap-1.5 px-5 text-[13px] font-semibold" style={{ color: dark ? "#ffd2cf" : "#b3261e" }}>
            <svg aria-hidden viewBox="0 0 16 16" className="h-3.5 w-3.5 shrink-0" fill="currentColor">
              <path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm-.75 3.5h1.5v4.5h-1.5V5Zm0 5.5h1.5V12h-1.5v-1.5Z" />
            </svg>
            {error}
          </p>
        ) : null}
      </div>
      {hint && !error && (
        <p id={fieldId + "-hint"} className="mt-2 px-5 text-[13px]" style={{ color: ink, opacity: 0.6 }}>
          {hint}
        </p>
      )}
    </div>
  );
}
Notes

About this pattern

Glass forms usually bolt a bright focus ring onto a blurred box, and the ring is the loudest thing on the page. In this field, focus changes the material instead. At rest the field is thin, nearly clear glass; focus it and a deeper pane blooms outward from the exact point you clicked, or from where the text starts when you arrive by keyboard, with more blur, more saturation, a brighter rim and, in Chromium, a refracting edge. Leaving drains it back to the same point. The two panes are siblings rather than nested, so each blurs the page behind it rather than the other. The label floats clear on focus or once there is a value; an error tints the glass warm and is announced; a hint sits below. It is a full pill on one line, the kit's control shape, and a 24px-radius textarea when multiline, with autofill's paint neutralised so the glass stays glass.

Curator’s note

Part of the Glassmorphism kit, drop 2. Free: a single form control, with the state (focus origin, floating label, validation) solved inside it.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A text input whose label floats from placeholder position into a caption on focus.

inputformlabel
Featured
NewPro

The error pattern that actually works with a screen reader: focus the summary, link to the fields, punish late.

formvalidationa11y
Pro

A validated wizard with per-step gating, an editable review step, and a real success state.

formwizardmulti-step