1"use client";
2
3import { useId, type CSSProperties, type ReactNode } from "react";
4import { useReducedMotion } from "framer-motion";
5
6export type IconMaterialName = "line" | "spark" | "neon" | "aurora" | "chrome" | "mercury" | "liquid" | "glass";
7
8/**
9 * Looks for the icons: wrap one icon, or a whole grid of them, and they all take on the same finish.
10 *
11 * line plain strokes in the current colour (the default; nothing is added)
12 * spark strokes in the current colour, with each icon's small accent details in colour:
13 * give the icons an `accent`, e.g. <BellIcon accent="#ff3b30" />
14 * neon a glow around every stroke, in the current colour
15 * aurora every stroke painted with a blue-to-orange spectrum
16 * chrome every stroke painted as polished metal (looks best on a dark background)
17 * mercury liquid metal: the strokes round and thicken into a lit tube, and a reflected room of light and
18 * dark bands flows slowly along them (best on a dark background)
19 * liquid the strokes in the current colour as a wet droplet: where they touch they melt together, with a
20 * glossy highlight, and the whole icon wobbles like jelly while the pointer is over it
21 * glass clear glass: a translucent body with a bright edge, a fringe of red and blue where the lens bends
22 * the light, and a sheen that drifts across (best on a dark or colourful background)
23 *
24 * It only restyles, so it works with any icon that draws in currentColor and has a 24-unit viewBox. The three
25 * liquid looks are pure SVG (gradients and filters): no canvas, no WebGL, nothing to load, and they hold still
26 * for visitors who have reduced motion switched on.
27 */
28
29const AURORA: [string, string][] = [["0", "#0090f7"], ["0.3", "#6e5bff"], ["0.55", "#ba62fc"], ["0.78", "#f2416b"], ["1", "#f55600"]];
30const CHROME: [string, string][] = [["0", "#f4f5fb"], ["0.22", "#9ea3b6"], ["0.38", "#ffffff"], ["0.55", "#6d7183"], ["0.72", "#e8e9f2"], ["0.84", "#ffe3b8"], ["1", "#bfc3d4"]];
31// The room mercury reflects, as one stripe of light: bright sky, soft grey, two hard dark bands and a warm window.
32// It starts and ends on the same colour so it can repeat and flow without a seam.
33const MERCURY: [string, string][] = [
34 ["0", "#f6f7fc"], ["0.12", "#d6d9e8"], ["0.26", "#8e93aa"], ["0.32", "#262833"], ["0.4", "#a4a9bf"], ["0.52", "#ffffff"],
35 ["0.6", "#646a80"], ["0.65", "#1c1e27"], ["0.73", "#cdd1e3"], ["0.85", "#ffe8c0"], ["1", "#f6f7fc"],
36];
37const GLASS: [string, string, number][] = [["0", "#ffffff", 0.95], ["0.35", "#e6f0ff", 0.55], ["0.7", "#cfe0ff", 0.3], ["1", "#ffffff", 0.7]];
38
39export function IconMaterial({
40 material = "line",
41 color,
42 className,
43 style,
44 children,
45}: {
46 material?: IconMaterialName;
47 /** The current colour for everything inside. Left out, it inherits. */
48 color?: string;
49 className?: string;
50 style?: CSSProperties;
51 children: ReactNode;
52}) {
53 const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
54 const scope = `nx-mat-${uid}`;
55 const reduce = useReducedMotion();
56 const flat = material === "aurora" || material === "chrome";
57 const stops = material === "aurora" ? AURORA : CHROME;
58 const liquidish = material === "mercury" || material === "liquid" || material === "glass";
59
60 // An inline <style> (not a class in a stylesheet), so that url(#…) points into this page's own document.
61 // Filters go on each top-level shape of an icon (not the <svg>), so their units are the icon's own 24-unit grid
62 // and a look is the same size-for-size at 16px or 160px.
63 let css = "";
64 if (material === "neon") {
65 css = `.${scope} svg{filter:drop-shadow(0 0 1.5px currentColor) drop-shadow(0 0 6px currentColor)}`;
66 } else if (flat) {
67 css =
68 `.${scope} svg *:not([stroke="none"]){stroke:url(#${scope}) !important}` +
69 `.${scope} svg *[fill]:not([fill="none"]){fill:url(#${scope}) !important}` +
70 (material === "chrome" ? `.${scope} svg{filter:drop-shadow(0 1px 1px rgba(0,0,0,.35))}` : "");
71 } else if (liquidish) {
72 css =
73 `.${scope} svg *:not([stroke="none"]){stroke:url(#${scope}) !important}` +
74 `.${scope} svg *[fill]:not([fill="none"]){fill:url(#${scope}) !important}` +
75 `.${scope} svg>*:not(defs){filter:url(#${scope}-f)}` +
76 (material === "liquid" && !reduce ? `.${scope} svg:hover>*:not(defs){filter:url(#${scope}-w)}` : "");
77 }
78
79 const flow = !reduce && (material === "mercury" || material === "glass");
80
81 return (
82 <div className={[scope, className].filter(Boolean).join(" ")} style={{ color, ...style }} data-material={material}>
83 {css ? <style>{css}</style> : null}
84 {flat ? (
85 <svg width="0" height="0" aria-hidden="true" style={{ position: "absolute" }} focusable="false">
86 <defs>
87 <linearGradient id={scope} gradientUnits="userSpaceOnUse" x1="3" y1="21" x2="21" y2="3">
88 {stops.map(([offset, c]) => (
89 <stop key={offset} offset={offset} stopColor={c} />
90 ))}
91 </linearGradient>
92 </defs>
93 </svg>
94 ) : null}
95
96 {material === "mercury" ? (
97 <svg width="0" height="0" aria-hidden="true" style={{ position: "absolute" }} focusable="false">
98 <defs>
99 {/* one stripe of reflected room, repeated, so sliding it by one period loops without a seam */}
100 <linearGradient id={scope} gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="24" y2="0" spreadMethod="repeat">
101 {MERCURY.map(([offset, c]) => (
102 <stop key={offset} offset={offset} stopColor={c} />
103 ))}
104 {flow ? <animateTransform attributeName="gradientTransform" type="translate" from="0 0" to="24 -24" dur="7s" repeatCount="indefinite" /> : null}
105 </linearGradient>
106 <filter id={`${scope}-f`} filterUnits="userSpaceOnUse" x="-4" y="-4" width="32" height="32" colorInterpolationFilters="sRGB">
107 {/* round the strokes off and let touching ones melt together */}
108 <feGaussianBlur in="SourceAlpha" stdDeviation="0.5" result="soft" />
109 <feColorMatrix in="soft" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 16 -6" result="shape" />
110 {/* a smooth height map of that shape, lit from the top left like a tube of metal */}
111 <feGaussianBlur in="shape" stdDeviation="0.65" result="relief" />
112 <feSpecularLighting in="relief" surfaceScale="2.6" specularConstant="1.2" specularExponent="28" lightingColor="#ffffff" result="shine">
113 <fePointLight x="3" y="0" z="13" />
114 </feSpecularLighting>
115 <feComposite in="shine" in2="shape" operator="in" result="glint" />
116 <feComposite in="SourceGraphic" in2="shape" operator="in" result="body" />
117 <feComposite in="body" in2="glint" operator="arithmetic" k1="0" k2="1" k3="0.9" k4="0" result="lit" />
118 <feDropShadow in="lit" dx="0" dy="0.5" stdDeviation="0.45" floodColor="#000000" floodOpacity="0.42" />
119 </filter>
120 </defs>
121 </svg>
122 ) : null}
123
124 {material === "liquid" ? (
125 <svg width="0" height="0" aria-hidden="true" style={{ position: "absolute" }} focusable="false">
126 <defs>
127 <linearGradient id={scope} gradientUnits="userSpaceOnUse" x1="3" y1="3" x2="21" y2="21">
128 <stop offset="0" stopColor="currentColor" style={{ stopColor: "color-mix(in srgb, currentColor 55%, white)" }} />
129 <stop offset="0.5" stopColor="currentColor" />
130 <stop offset="1" stopColor="currentColor" style={{ stopColor: "color-mix(in srgb, currentColor 70%, black)" }} />
131 </linearGradient>
132 <filter id={`${scope}-f`} filterUnits="userSpaceOnUse" x="-4" y="-4" width="32" height="32" colorInterpolationFilters="sRGB">
133 <LiquidCore wobble={false} />
134 </filter>
135 <filter id={`${scope}-w`} filterUnits="userSpaceOnUse" x="-4" y="-4" width="32" height="32" colorInterpolationFilters="sRGB">
136 <LiquidCore wobble />
137 </filter>
138 </defs>
139 </svg>
140 ) : null}
141
142 {material === "glass" ? (
143 <svg width="0" height="0" aria-hidden="true" style={{ position: "absolute" }} focusable="false">
144 <defs>
145 <linearGradient id={scope} gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="24" y2="0" spreadMethod="repeat">
146 {GLASS.map(([offset, c, o]) => (
147 <stop key={offset} offset={offset} stopColor={c} stopOpacity={o} />
148 ))}
149 {flow ? <animateTransform attributeName="gradientTransform" type="translate" from="0 0" to="24 -24" dur="9s" repeatCount="indefinite" /> : null}
150 </linearGradient>
151 <filter id={`${scope}-f`} filterUnits="userSpaceOnUse" x="-4" y="-4" width="32" height="32" colorInterpolationFilters="sRGB">
152 {/* a bright rim where the glass catches the light */}
153 <feGaussianBlur in="SourceAlpha" stdDeviation="0.3" result="soft" />
154 <feSpecularLighting in="soft" surfaceScale="2" specularConstant="1" specularExponent="13" lightingColor="#ffffff" result="rim">
155 <fePointLight x="2" y="0" z="9" />
156 </feSpecularLighting>
157 <feComposite in="rim" in2="SourceAlpha" operator="in" result="edge" />
158 {/* the lens bends red and blue light apart: a fringe on each side of every stroke */}
159 <feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="red" />
160 <feOffset in="red" dx="-0.32" dy="0" result="redShift" />
161 <feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="green" />
162 <feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="blue" />
163 <feOffset in="blue" dx="0.32" dy="0" result="blueShift" />
164 <feBlend in="redShift" in2="green" mode="screen" result="rg" />
165 <feBlend in="rg" in2="blueShift" mode="screen" result="split" />
166 <feComposite in="edge" in2="split" operator="arithmetic" k1="0" k2="0.9" k3="1" k4="0" result="glass" />
167 <feDropShadow in="glass" dx="0" dy="0.6" stdDeviation="0.55" floodColor="#0a1030" floodOpacity="0.5" />
168 </filter>
169 </defs>
170 </svg>
171 ) : null}
172
173 {children}
174 </div>
175 );
176}
177
178/** The shared part of the liquid filter: melt, light, and (on hover) a slow jelly wobble. */
179function LiquidCore({ wobble }: { wobble: boolean }) {
180 return (
181 <>
182 {wobble ? (
183 <>
184 <feTurbulence type="fractalNoise" baseFrequency="0.05 0.07" numOctaves="1" seed="3" result="noise">
185 <animate attributeName="baseFrequency" values="0.05 0.07;0.08 0.05;0.05 0.07" dur="2.6s" repeatCount="indefinite" />
186 </feTurbulence>
187 <feDisplacementMap in="SourceGraphic" in2="noise" scale="1.1" xChannelSelector="R" yChannelSelector="G" result="wobbled" />
188 </>
189 ) : null}
190 <feGaussianBlur in={wobble ? "wobbled" : "SourceGraphic"} stdDeviation="0.7" result="soft" />
191 <feColorMatrix in="soft" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9" result="melted" />
192 <feGaussianBlur in="melted" stdDeviation="0.7" result="relief" />
193 <feSpecularLighting in="relief" surfaceScale="3" specularConstant="1.3" specularExponent="18" lightingColor="#ffffff" result="wet">
194 <fePointLight x="4" y="0" z="10" />
195 </feSpecularLighting>
196 <feComposite in="wet" in2="melted" operator="in" result="gloss" />
197 <feComposite in="melted" in2="gloss" operator="arithmetic" k1="0" k2="1" k3="0.95" k4="0" result="lit" />
198 <feDropShadow in="lit" dx="0" dy="0.55" stdDeviation="0.5" floodColor="currentColor" floodOpacity="0.45" />
199 </>
200 );
201}
202