liten

Frost Pane

Content behind frosted glass that the user wipes clear with a circular opening that chases the pointer, or defrosts with a click.

Facade

Facade

Monolith, soft shadow. Wipe the glass, or tap to defrost.

A pane of frosted glass laid over any content. Move across it and a soft circular opening wipes the frost away locally, chasing the pointer with springy lag. Click, or press Enter or Space, and the whole pane defrosts in one quick dissolve; toggling back refrosts slower, because leaving is deliberate. The pane is a real focusable surface, so keyboard users get the full defrost without a pointer. Built with Motion.

Installation

Complete the shared Setup first, then add Motion.

Terminal
bun add motion

Copy the component into components/ui/frost-pane.tsx.

components/ui/frost-pane.tsx
'use client';import * as React from 'react';import {  AnimatePresence,  motion,  useMotionTemplate,  useReducedMotion,  useSpring,  useTransform,} from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const FOCUS =  'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:[outline-color:var(--frost-accent)]';const CHASE = { stiffness: 180, damping: 22 } as const;const OPEN = { stiffness: 120, damping: 20 } as const;const FALLOFF = 60;export type FrostPaneProps = {  children: React.ReactNode;  accent?: string;  radius?: number;  holeRadius?: number;  defaultPinned?: boolean;  className?: string;};export function FrostPane({  children,  accent = '#f0883e',  radius = 14,  holeRadius = 140,  defaultPinned = false,  className,}: FrostPaneProps) {  const reduce = useReducedMotion();  const ref = React.useRef<HTMLDivElement>(null);  const [pinned, setPinned] = React.useState(defaultPinned);  const [interacted, setInteracted] = React.useState(defaultPinned);  const x = useSpring(0, CHASE);  const y = useSpring(0, CHASE);  const holeP = useSpring(0, OPEN);  const inner = useTransform(holeP, (p) =>    Math.max((holeRadius - FALLOFF) * p, 0),  );  const outer = useTransform(holeP, (p) => Math.max(holeRadius * p, 0.5));  const mask = useMotionTemplate`radial-gradient(circle at ${x}px ${y}px, transparent ${inner}px, black ${outer}px)`;  const toggle = () => {    setPinned((p) => !p);    setInteracted(true);  };  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {    if (reduce || e.pointerType === 'touch') return;    const el = ref.current;    if (!el) return;    const r = el.getBoundingClientRect();    x.set(e.clientX - r.left);    y.set(e.clientY - r.top);    holeP.set(1);  };  const onLeave = () => {    holeP.set(0);  };  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {    if (e.key === 'Enter' || e.key === ' ') {      e.preventDefault();      toggle();    }  };  const vars = { '--frost-accent': accent } as React.CSSProperties;  return (    <motion.div      ref={ref}      role="button"      tabIndex={0}      aria-pressed={pinned}      aria-label={pinned ? 'Refrost the pane' : 'Defrost the pane'}      onClick={toggle}      onKeyDown={onKeyDown}      onPointerMove={onMove}      onPointerLeave={onLeave}      whileTap={reduce ? undefined : { scale: 0.99 }}      style={{ ...vars, borderRadius: radius }}      className={cn(        'relative cursor-pointer touch-manipulation overflow-hidden select-none',        FOCUS,        className,      )}    >      {children}      <motion.div        aria-hidden        initial={false}        animate={{          opacity: pinned ? 0 : 1,          backdropFilter: pinned            ? 'blur(0px) saturate(1)'            : 'blur(10px) saturate(0.9)',        }}        transition={{ duration: pinned ? 0.3 : 0.5, ease: EASE_OUT }}        style={{          borderRadius: radius,          ...(reduce            ? undefined            : {                maskImage: mask,                WebkitMaskImage: mask,              }),        }}        className={cn(          'bloom-edge pointer-events-none absolute inset-0',          'bg-white/40 dark:bg-[#101012]/45',        )}      />      <AnimatePresence>        {!interacted && (          <motion.span            aria-hidden            initial={false}            exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}            className={cn(              'bloom-edge pointer-events-none absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] font-medium tracking-[-0.01em]',              'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900',              'dark:from-[#272727] dark:to-[#191919] dark:text-white',              'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_6px_16px_-6px_rgba(0,0,0,0.2)]',              'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_6px_16px_-10px_rgba(0,0,0,0.45)]',            )}          >            <span              className="size-1.5 rounded-full"              style={{ backgroundColor: 'var(--frost-accent)' }}            />            tap to defrost          </motion.span>        )}      </AnimatePresence>    </motion.div>  );}

Usage

Wrap the content to hide. The pane clips to radius, overlays the frost, and handles pointer, click, and keyboard itself.

Example.tsx
import { FrostPane } from '@/components/ui/frost-pane';

export default function Example() {
  return (
    <FrostPane radius={14}>
      <img src="/media/gallery-03.jpg" alt="Facade" />
    </FrostPane>
  );
}

Examples

holeRadius sizes the wipe; accent colors only the focus ring and the hint chip's dot, never the glass.

Ascent

Ascent

Concrete stair, 2024. A tighter opening, cyan focus ring.

Props

PropTypeDefaultDescription
childrenReact.ReactNode-The content behind the glass.
accentstring"#f0883e"Focus ring and the hint chip's dot.
radiusnumber14Border radius of the pane and its clip.
holeRadiusnumber140Radius of the clear opening that follows the pointer.
defaultPinnedbooleanfalseStart fully defrosted.
classNamestring-Forwarded to the root surface.
On this page0%