Window
Glass effect with highlights
"use client";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { XIcon } from "lucide-react";
import { type HTMLMotionProps, MotionValue, motion, useScroll } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, {
type RefObject,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cn } from "@/lib/utils";
type GlassThickness =
| "none"
| "thinnest"
| "thinner"
| "thin"
| "normal"
| "thick"
| "thicker"
| "thickest";
type WindowControlsProps =
| boolean
| {
/**
* The URL used when clicking the close button.
* @default `router.back()`
*/
href?: string;
};
interface WindowApiProps {
/**
* Wrap content in a scroll area.
*
* You can use `useWindow` to get the scroll position of the window in the children.
* @default false
*/
scroll?: boolean;
/**
* The thickness of the glass effect.
* @default "normal"
*/
thickness?: GlassThickness;
/**
* The controls to display in the window.
* @default false
*/
controls?: WindowControlsProps;
/**
* The root className for the window wrapper.
*/
rootClassName?: string;
}
interface WindowProps extends HTMLMotionProps<"div">, WindowApiProps {
children: React.ReactNode;
}
export const getThickness = (thickness: GlassThickness) => {
switch (thickness) {
case "thinnest":
return 24;
case "thinner":
return 32;
case "thin":
return 42;
case "normal":
return 48;
case "thick":
return 64;
case "thicker":
return 72;
case "thickest":
return 96;
default:
return 24;
}
};
export const getRings = (thickness: GlassThickness) => {
switch (thickness) {
case "thinnest":
return "0px 3px 6.5px 0px rgba(0, 0, 0, 0.05), -0.25px 0.35px 0.15px -1.5px rgba(255, 255, 255, 0.15) inset, 0px 0.35px 2px 0px rgba(255, 255, 255, 0.15) inset";
case "thinner":
return "0px 4px 8px 0px rgba(0, 0, 0, 0.05), -0.35px 0.55px 0.25px -1.5px rgba(255, 255, 255, 0.2) inset, 0px 0.55px 2px 0px rgba(255, 255, 255, 0.2) inset";
case "thin":
return "0px 6px 10px 0px rgba(0, 0, 0, 0.05), -0.45px 0.75px 0.35px -1.5px rgba(255, 255, 255, 0.25) inset, 0px 0.75px 2px 0px rgba(255, 255, 255, 0.25) inset";
case "normal":
return "0px 8px 12px 0px rgba(0, 0, 0, 0.05), -0.55px 1px 0.45px -1.5px rgba(255, 255, 255, 0.3) inset, 0px 1px 2px 0px rgba(255, 255, 255, 0.3) inset";
case "thick":
return "0px 12px 16px 0px rgba(0, 0, 0, 0.05), -0.65px 1.25px 0.65px -1.5px rgba(255, 255, 255, 0.35) inset, 0px 1.25px 2px 0px rgba(255, 255, 255, 0.35) inset";
case "thicker":
return "0px 18px 22px 0px rgba(0, 0, 0, 0.05), -0.75px 1.75px 0.75px -1.5px rgba(255, 255, 255, 0.35) inset, 0px 1.75px 6px 0px rgba(255, 255, 255, 0.35) inset";
case "thickest":
return "0px 24px 28px 0px rgba(0, 0, 0, 0.05), -0.85px 1.85px 0.85px -1.5px rgba(255, 255, 255, 0.35) inset, 0px 1.85px 6px 0px rgba(255, 255, 255, 0.35) inset";
default:
return "0px 3px 6.5px 0px rgba(0, 0, 0, 0.05), -0.25px 0.35px 0.15px -1.5px rgba(255, 255, 255, 0.15) inset, 0px 0.35px 2px 0px rgba(255, 255, 255, 0.15) inset";
}
};
export const getHighlightStroke = (thickness: GlassThickness) => {
switch (thickness) {
case "thinnest":
return "[--mask-stroke:0.75px]";
case "thinner":
return "[--mask-stroke:1px]";
case "thin":
return "[--mask-stroke:1.25px]";
case "normal":
return "[--mask-stroke:1.5px]";
case "thick":
return "[--mask-stroke:1.75px]";
case "thicker":
return "[--mask-stroke:1.85px]";
case "thickest":
return "[--mask-stroke:1.9px]";
default:
return "[--mask-stroke:0.75px]";
}
};
const getHighlightOpacity = (thickness: GlassThickness) => {
switch (thickness) {
case "thinnest":
return 0.15;
case "thinner":
return 0.175;
case "thin":
return 0.2;
case "normal":
return 0.225;
case "thick":
return 0.25;
case "thicker":
return 0.275;
case "thickest":
return 0.3;
default:
return 0.15;
}
};
const CONSTANTS = {
SATURATION: 1.5,
VAR_RADIUS: "[--radius:34px]",
VAR_DIAMETER: "[--diameter:68px]",
};
const maskComposite = ["exclude", "intersect", "subtract", "intersect", "subtract", "add"];
const defaultHighlightStyle = {
borderRadius: `var(--radius)`,
maskSize: "100% 100%",
WebkitMaskSize: "100% 100%",
maskRepeat: "no-repeat",
WebkitMaskRepeat: "no-repeat",
};
const leftTopHighlight =
"conic-gradient(from 270deg at var(--radius) var(--radius), transparent 0deg, white 45deg, transparent 170deg), transparent";
const leftTopMaskImage = [
"linear-gradient(to right, black, black)",
"linear-gradient(to right, transparent var(--mask-stroke), black calc(var(--mask-stroke) * 2))",
"linear-gradient(to bottom, transparent var(--mask-stroke), black calc(var(--mask-stroke) * 2))",
"linear-gradient(to right, black calc(var(--radius) - var(--mask-stroke)), transparent var(--radius))",
"linear-gradient(to bottom, black calc(var(--radius) - var(--mask-stroke)), transparent var(--radius))",
"radial-gradient(var(--diameter) var(--diameter) at var(--radius) var(--radius), black var(--mask-inner-distance), transparent var(--mask-outer-distance))",
];
const leftTopHighlightStyle = {
background: leftTopHighlight,
maskImage: leftTopMaskImage.join(", "),
maskComposite: maskComposite.join(", "),
...defaultHighlightStyle,
};
const rightBottomHighlight =
"conic-gradient(from 60deg at var(--radius) var(--radius), transparent 0deg, white 65deg, transparent 160deg), transparent";
const rightBottomMaskImage = [
"linear-gradient(to left, black, black)",
"linear-gradient(to left, transparent var(--mask-stroke), black calc(var(--mask-stroke) * 2))",
"linear-gradient(to top, transparent var(--mask-stroke), black calc(var(--mask-stroke) * 2))",
"linear-gradient(to left, black calc(var(--radius) - var(--mask-stroke)), transparent var(--radius))",
"linear-gradient(to top, black calc(var(--radius) - var(--mask-stroke)), transparent var(--radius))",
"radial-gradient(var(--diameter) var(--diameter) at calc(100% - var(--radius)) calc(100% - var(--radius)), black var(--mask-inner-distance), transparent var(--mask-outer-distance))",
];
const rightBottomHighlightStyle = {
background: rightBottomHighlight,
maskImage: rightBottomMaskImage.join(", "),
maskComposite: maskComposite.join(", "),
...defaultHighlightStyle,
};
const WindowContext = React.createContext<{
scrollY: MotionValue<number>;
width: number | undefined;
height: number | undefined;
visible: boolean;
setVisible: (visible: boolean) => void;
windowId: string;
}>({
scrollY: new MotionValue(0),
width: 0,
height: 0,
visible: false,
setVisible: () => {},
windowId: "",
});
const useWindow = () => {
const context = React.useContext(WindowContext);
if (!context) {
throw new Error("useWindowContext must be used within a WindowContext");
}
return context;
};
const Window = React.forwardRef<HTMLDivElement, WindowProps>(
(
{
children,
className,
rootClassName,
thickness,
style,
scroll = false,
controls = false,
...props
}: WindowProps,
ref
) => {
const windowId = useId();
const localRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(true);
// strip out *-h-*, h-* classes classes
const scrollWindowRegex = /-h-.*|h-.*|^max-h-.*|^min-h-.*|^h-.*|h-.*|max-h-.*|min-h-.*/g;
const scrollWindowClassesName = className?.match(scrollWindowRegex)?.join(" ") || "";
const restClassesName = className?.replace(scrollWindowRegex, "") || "";
// get rounded-* classes
const roundedRegex = /rounded-.*|^rounded/g;
const roundedClassesName = className?.match(roundedRegex)?.join(" ") || "";
useImperativeHandle(ref, () => localRef.current!);
const { scrollY } = useScroll({
container: scroll ? localRef : undefined,
layoutEffect: false,
});
const { width, height } = useResizeObserver({
ref: localRef as RefObject<HTMLDivElement>,
});
// Extract role and aria-label from props if provided
const { role, "aria-label": ariaLabel, ...restProps } = props;
return (
<WindowContext.Provider value={{ scrollY, width, height, visible, setVisible, windowId }}>
<motion.div
key={`${windowId}-wrapper`}
className={cn("relative flex flex-col items-center justify-center", rootClassName)}
initial={{
opacity: 1,
scale: 1,
}}
animate={{
opacity: visible ? 1 : 0,
scale: visible ? 1 : 0.99,
transition: {
duration: 0.4,
},
}}
>
<motion.div
className={cn(
"relative overflow-hidden",
"before:absolute before:inset-0 before:z-[-1] before:rounded-[var(--radius)]",
"before:bg-[#80808030]",
"min-h-[64px] min-w-[64px]",
CONSTANTS.VAR_DIAMETER,
CONSTANTS.VAR_RADIUS,
restClassesName,
!scroll && scrollWindowClassesName
)}
style={{
backdropFilter:
thickness === "none"
? "none"
: `saturate(${CONSTANTS.SATURATION}) blur(${getThickness(thickness || "normal")}px) brightness(0.85)`,
WebkitBackdropFilter:
thickness === "none"
? "none"
: `saturate(${CONSTANTS.SATURATION}) blur(${getThickness(thickness || "normal")}px) brightness(0.85)`,
borderRadius: `var(--radius)`,
...style,
}}
role={role || "region"}
aria-label={ariaLabel || "Window content"}
{...restProps}
>
{/* HIGHLIGHTRINGS */}
<motion.div
className="pointer-events-none absolute inset-x-0 z-40 h-full w-full"
style={{
boxShadow: getRings(thickness || "normal"),
borderRadius: `var(--radius)`,
top: 0,
}}
aria-hidden="true"
/>
<motion.div
className={cn(
getHighlightStroke(thickness || "normal"),
"pointer-events-none absolute inset-[-0.75px] z-40",
"[--mask-inner-distance:calc(50%-var(--mask-stroke)-var(--mask-stroke))] [--mask-outer-distance:calc(50%-var(--mask-stroke))]"
)}
style={{
...leftTopHighlightStyle,
opacity: getHighlightOpacity(thickness || "normal") + 0.35,
}}
aria-hidden="true"
/>
<motion.div
className={cn(
getHighlightStroke(thickness || "normal"),
"pointer-events-none absolute inset-[-0.25px] z-40",
"[--mask-inner-distance:calc(50%-var(--mask-stroke)-var(--mask-stroke))] [--mask-outer-distance:calc(50%-var(--mask-stroke))]"
)}
style={{
...rightBottomHighlightStyle,
opacity: getHighlightOpacity(thickness || "normal") - 0.05,
}}
aria-hidden="true"
/>
{scroll ? (
<ScrollAreaPrimitive.Root
className={cn("relative", scrollWindowClassesName)}
aria-label={ariaLabel ? `Scrollable ${ariaLabel}` : "Scrollable content"}
>
<ScrollAreaPrimitive.Viewport
className={cn(
"h-full w-full",
roundedClassesName.length > 0 ? roundedClassesName : `rounded-[var(--radius)]`,
{
"!overflow-visible": scrollWindowClassesName.length === 0,
}
)}
ref={localRef}
tabIndex={0}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
) : (
children
)}
</motion.div>
{controls && (
<WindowControls href={typeof controls === "object" ? controls.href : undefined} />
)}
</motion.div>
</WindowContext.Provider>
);
}
);
Window.displayName = "Window";
const WindowControls = ({ href }: { href?: string }) => {
const router = useRouter();
const buttonRef = useRef<HTMLAnchorElement>(null);
const { setVisible } = useWindow();
const onNavigate = (e: { preventDefault: () => void }) => {
e.preventDefault();
setVisible(false);
setTimeout(() => {
if (href) {
router.push(href);
} else {
router.back();
}
}, 450);
};
// Handle Escape key press
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (document.activeElement === buttonRef.current) {
// If button is already focused, trigger the action
onNavigate(e);
} else {
// Focus the button on first Escape press
buttonRef.current?.focus();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [href, router]);
return (
<motion.div
className="group/controls absolute inset-x-0 bottom-[var(--window-controls-bottom,-37px)] z-50 mx-auto inline-flex h-[37px] w-[212px] shrink-0 items-center justify-start gap-4 pt-[22px] pr-[28px] pb-px"
layout
layoutId="navigation-bar"
role="toolbar"
aria-label="Window controls"
>
<Link
href={href || "/"}
ref={buttonRef}
onNavigate={onNavigate}
className={cn(
"h-[37px] w-[37px]",
"group/close-btn",
"peer/close-btn",
"flex items-center justify-center",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent",
"rounded-full"
)}
aria-label="Close window"
title={href ? "Navigate to previous page" : "Go back"}
>
<span
className={cn(
"pointer-events-none",
"size-3.5 rounded-[100px] bg-white/30 backdrop-blur-[20px]",
"transition-all duration-300",
"flex items-center justify-center",
"group-hover/close-btn:size-6 group-hover/close-btn:bg-white/100",
"group-active/close-btn:size-4 group-active/close-btn:bg-white/100",
"group-focus-visible/close-btn:size-6 group-focus-visible/close-btn:bg-white/100"
)}
>
<XIcon className="size-3.5 text-[#333] opacity-0 group-hover/close-btn:size-3 group-hover/close-btn:opacity-100 group-focus-visible/close-btn:size-3 group-focus-visible/close-btn:opacity-100" />
</span>
</Link>
<div
className={cn(
"relative h-3.5 w-[136px] rounded-[100px] bg-white/30 backdrop-blur-[20px]",
"transition-all duration-300",
"peer-hover/close-btn:ml-[10px] peer-hover/close-btn:w-[126px] peer-hover/close-btn:bg-white/50",
"peer-focus-visible/close-btn:ml-[10px] peer-focus-visible/close-btn:w-[126px] peer-focus-visible/close-btn:bg-white/50"
)}
aria-hidden="true"
></div>
</motion.div>
);
};
export { Window, WindowControls, useWindow };
export type { WindowProps, WindowApiProps, GlassThickness };Window requires scroll-area component to be installed.
npx shadcn@latest add scroll-areaThickness
The Window component has a thickness prop that can be used to set the thickness of the window.
None
Thinnest
Thinner
Thin
Normal
Thick
Thicker
Thickest
import { Window } from "../core/window";
export const WindowExample = () => {
return (
<>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="none">
<p className="text-white/80">None</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="thinnest">
<p className="text-white/80">Thinnest</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="thinner">
<p className="text-white/80">Thinner</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="thin">
<p className="text-white/80">Thin</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="normal">
<p className="text-white/80">Normal</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="thick">
<p className="text-white/80">Thick</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="thicker">
<p className="text-white/80">Thicker</p>
</Window>
<Window className="flex h-[100px] w-[150px] items-center justify-center" thickness="thickest">
<p className="text-white/80">Thickest</p>
</Window>
</>
);
};Highlights
You might wonder how the window is able to have a realistic glass effect with highlights.
Using css border? Well, this method will only go so far. If you take a closer look at the window, you can
see the edges are softened and the highlights has a gradient effect.
This is achieved using a combination of generic css and css image-mask to achieve the effect.
Then we use mask-composite to combine the masks. (Similar to layering in Photoshop)
For example, the left top highlight is made with 6 different css gradients.
//...
const maskComposite = [
"exclude",
"intersect",
"subtract",
"intersect",
"subtract",
"add",
];
const defaultHighlightStyle = {
borderRadius: CONSTANTS.BORDER_RADIUS,
maskSize: "100% 100%",
WebkitMaskSize: "100% 100%",
maskRepeat: "no-repeat",
WebkitMaskRepeat: "no-repeat",
};
const leftTopHighlight =
"conic-gradient(from 270deg at var(--radius) var(--radius), transparent 0deg, white 45deg, transparent 170deg), transparent";
const leftTopMaskImage = [
"linear-gradient(to right, black, black)",
"linear-gradient(to right, transparent var(--mask-stroke), black calc(var(--mask-stroke) * 2))",
"linear-gradient(to bottom, transparent var(--mask-stroke), black calc(var(--mask-stroke) * 2))",
"linear-gradient(to right, black calc(var(--radius) - var(--mask-stroke)), transparent var(--radius))",
"linear-gradient(to bottom, black calc(var(--radius) - var(--mask-stroke)), transparent var(--radius))",
"radial-gradient(var(--diameter) var(--diameter) at var(--radius) var(--radius), black var(--mask-inner-distance), transparent var(--mask-outer-distance))",
];
const leftTopHighlightStyle = {
background: leftTopHighlight,
maskImage: leftTopMaskImage.join(", "),
maskComposite: maskComposite.join(", "),
...defaultHighlightStyle,
};
//...
<motion.div
className={cn(
getHighlightStroke(thickness || "normal"),
"pointer-events-none absolute inset-[-0.75px] z-40",
"[--mask-inner-distance:calc(50%-var(--mask-stroke)-var(--mask-stroke))] [--mask-outer-distance:calc(50%-var(--mask-stroke))]",
)}
style={{
...leftTopHighlightStyle,
opacity: getHighlightOpacity(thickness || "normal") + 0.35,
}}
aria-hidden="true"
/>;API
| Prop | Type | Default |
|---|---|---|
scroll? | boolean | false |
thickness? | GlassThickness | "normal" |
controls? | WindowControlsProps | false |
rootClassName? | string | - |
Last updated on