Skip to content

Commit 97953bd

Browse files
✨ Add Font Effects category with Variable Font Cursor component and preview (#75)
1 parent dda443c commit 97953bd

File tree

8 files changed

+307
-3
lines changed

8 files changed

+307
-3
lines changed

packages/ui/categories-previews-list.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import other_patterns_preview from "./cuicui/other/patterns/preview";
44
import other_equalizer_preview from "./cuicui/other/equalizer/preview";
55
import other_qr_code_preview from "./cuicui/other/qr-code/preview";
66
import other_transition_wrappers_preview from "./cuicui/other/transition-wrappers/preview";
7+
import other_font_effects_preview from "./cuicui/other/font-effects/preview";
78
import other_cursors_preview from "./cuicui/other/cursors/preview";
89
import utils_catch_error_preview from "./cuicui/utils/catch-error/preview";
910
import utils_sleep_preview from "./cuicui/utils/sleep/preview";
@@ -88,6 +89,7 @@ export const categoriesPreviewsList: Record<string, () => JSX.Element> = {
8889
equalizer: other_equalizer_preview,
8990
"qr-code": other_qr_code_preview,
9091
"transition-wrappers": other_transition_wrappers_preview,
92+
"font-effects": other_font_effects_preview,
9193
cursors: other_cursors_preview,
9294
"catch-error": utils_catch_error_preview,
9395
sleep: utils_sleep_preview,

packages/ui/cuicui/hooks/use-mouse/index.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ interface MouseState {
1010
elementPositionY: number | null;
1111
}
1212

13-
export function useMouse(): [MouseState, RefObject<HTMLDivElement | null>] {
13+
export function useMouse(
14+
containerRef?: RefObject<HTMLElement | SVGElement | null>,
15+
): [MouseState, RefObject<HTMLDivElement | null>] {
1416
const [state, setState] = useState<MouseState>({
1517
x: null,
1618
y: null,
@@ -29,7 +31,18 @@ export function useMouse(): [MouseState, RefObject<HTMLDivElement | null>] {
2931
y: event.pageY,
3032
};
3133

32-
if (ref.current instanceof Element) {
34+
if (containerRef?.current instanceof Element) {
35+
const { left, top } = containerRef.current.getBoundingClientRect();
36+
const containerPositionX = left + window.scrollX;
37+
const containerPositionY = top + window.scrollY;
38+
const containerX = event.pageX - containerPositionX;
39+
const containerY = event.pageY - containerPositionY;
40+
41+
newState.elementX = containerX;
42+
newState.elementY = containerY;
43+
newState.elementPositionX = containerPositionX;
44+
newState.elementPositionY = containerPositionY;
45+
} else if (ref.current instanceof Element) {
3346
const { left, top } = ref.current.getBoundingClientRect();
3447
const elementPositionX = left + window.scrollX;
3548
const elementPositionY = top + window.scrollY;
@@ -53,7 +66,7 @@ export function useMouse(): [MouseState, RefObject<HTMLDivElement | null>] {
5366
return () => {
5467
document.removeEventListener("mousemove", handleMouseMove);
5568
};
56-
}, []);
69+
}, [containerRef]);
5770

5871
return [state, ref];
5972
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { CategoryMetaType } from "@/lib/types/component";
2+
import { CaseSensitiveIcon } from "lucide-react";
3+
4+
export const Category: CategoryMetaType = {
5+
name: "Font Effects",
6+
description:
7+
"An all bunch of creative effects that can be used in any project with any artisitic style",
8+
latestUpdateDate: new Date("2025-01-05"),
9+
icon: CaseSensitiveIcon,
10+
};
11+
12+
export default Category;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default function () {
2+
return (
3+
<div className="w-fit p-4 bg-neutral-400/15 rounded-xl flex flex-col justify-center items-center space-y-2">
4+
{/* 3 characters in <p> */}
5+
<p className="inline text-neutral-500/70 text-lg font-medium tracking-wide">
6+
<span className="text-xl">c</span>
7+
<span className="text-2xl">u</span>
8+
<span className="text-3xl">i</span>
9+
<span className="text-xl">c</span>
10+
<span className="text-2xl">u</span>
11+
<span className="text-3xl">i</span>
12+
</p>
13+
14+
{/* Variable font preview bars */}
15+
<div className="w-32 h-1 bg-neutral-400/40 rounded-full" />
16+
<div className="w-32 h-2 bg-neutral-400/40 rounded-full" />
17+
<div className="w-32 h-3 bg-neutral-400/40 rounded-full" />
18+
</div>
19+
);
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { ComponentMetaType } from "@/lib/types/component";
2+
3+
export const Component: ComponentMetaType = {
4+
name: "Variable Font Cursor",
5+
description:
6+
"A cursor that changes its font size based on the cursor position",
7+
inspiration: "Fancy Components",
8+
inspirationLink:
9+
"https://www.fancycomponents.dev/docs/components/text/variable-font-cursor-proximity",
10+
};
11+
12+
export default Component;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
import { useRef } from "react";
3+
4+
import { cn } from "@/cuicui/utils/cn";
5+
import { VariableFontCursorProximity } from "@/cuicui/other/font-effects/variable-font-cursor/variable-font-proximity";
6+
7+
export function PreviewVariableFontCursor() {
8+
const containerRef = useRef<HTMLDivElement>(null);
9+
10+
return (
11+
<div
12+
className="dark:text-neutral-50 text-neutral-700 *:block"
13+
ref={containerRef}
14+
>
15+
<VariableFontCursorProximity
16+
className={cn("text-2xl md:text-3xl lg:text-4xl")}
17+
radiusZoomingZone={200}
18+
containerRef={containerRef}
19+
>
20+
This is
21+
</VariableFontCursorProximity>
22+
<VariableFontCursorProximity
23+
className={cn("text-3xl md:text-4xl lg:text-5xl")}
24+
radiusZoomingZone={200}
25+
containerRef={containerRef}
26+
>
27+
inspired by
28+
</VariableFontCursorProximity>
29+
<VariableFontCursorProximity
30+
className={cn("text-4xl md:text-5xl lg:text-6xl")}
31+
radiusZoomingZone={200}
32+
containerRef={containerRef}
33+
>
34+
Fancy components
35+
</VariableFontCursorProximity>
36+
</div>
37+
);
38+
}
39+
40+
export default PreviewVariableFontCursor;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"use client";
2+
import { type ComponentProps, type RefObject, useMemo, useRef } from "react";
3+
import { motion, useAnimationFrame } from "motion/react";
4+
import { useMouse } from "@/cuicui/hooks/use-mouse";
5+
6+
interface TextProps {
7+
children: string;
8+
fromFontVariationSettings?: string;
9+
toFontVariationSettings?: string;
10+
containerRef: RefObject<HTMLDivElement | null>;
11+
radiusZoomingZone?: number;
12+
falloff?: "linear" | "exponential" | "gaussian";
13+
}
14+
15+
export const VariableFontCursorProximity = ({
16+
children,
17+
fromFontVariationSettings = "'wght' 400, 'slnt' 0",
18+
toFontVariationSettings = "'wght' 900, 'slnt' -10",
19+
containerRef,
20+
radiusZoomingZone = 50,
21+
falloff = "linear",
22+
className,
23+
onClick,
24+
ref,
25+
...props
26+
}: TextProps & ComponentProps<"span">) => {
27+
const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);
28+
const interpolatedSettingsRef = useRef<string[]>([]);
29+
30+
const [mousePosition, _] = useMouse(containerRef);
31+
32+
// Parse the font variation settings strings. see the docs or the demo on how one should look like
33+
const parsedSettings = useMemo(() => {
34+
const fromSettings = new Map(
35+
fromFontVariationSettings
36+
.split(",")
37+
.map((s) => s.trim())
38+
.map((s) => {
39+
const [name, value] = s.split(" ");
40+
return [name.replace(/['"]/g, ""), Number.parseFloat(value)];
41+
}),
42+
);
43+
44+
const toSettings = new Map(
45+
toFontVariationSettings
46+
.split(",")
47+
.map((s) => s.trim())
48+
.map((s) => {
49+
const [name, value] = s.split(" ");
50+
return [name.replace(/['"]/g, ""), Number.parseFloat(value)];
51+
}),
52+
);
53+
54+
return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({
55+
axis,
56+
fromValue,
57+
toValue: toSettings.get(axis) ?? fromValue,
58+
}));
59+
}, [fromFontVariationSettings, toFontVariationSettings]);
60+
61+
const calculateDistance = (
62+
x1: number,
63+
y1: number,
64+
x2: number,
65+
y2: number,
66+
): number => {
67+
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
68+
};
69+
70+
const calculateFalloff = (distance: number): number => {
71+
const normalizedDistance = Math.min(
72+
Math.max(1 - distance / radiusZoomingZone, 0),
73+
1,
74+
);
75+
76+
switch (falloff) {
77+
case "exponential":
78+
return normalizedDistance ** 2;
79+
case "gaussian":
80+
return Math.exp(-((distance / (radiusZoomingZone / 2)) ** 2) / 2);
81+
// case "linear":
82+
default:
83+
return normalizedDistance;
84+
}
85+
};
86+
87+
useAnimationFrame(() => {
88+
if (!containerRef.current) {
89+
return;
90+
}
91+
const containerRect = containerRef.current.getBoundingClientRect();
92+
93+
letterRefs.current.forEach((letterRef, index) => {
94+
if (!(mousePosition.elementX && mousePosition.elementY)) {
95+
return;
96+
}
97+
if (!letterRef) {
98+
return;
99+
}
100+
101+
const rect = letterRef.getBoundingClientRect();
102+
const letterCenterX = rect.left + rect.width / 2 - containerRect.left;
103+
const letterCenterY = rect.top + rect.height / 2 - containerRect.top;
104+
105+
const distance = calculateDistance(
106+
mousePosition.elementX,
107+
mousePosition.elementY,
108+
letterCenterX,
109+
letterCenterY,
110+
);
111+
112+
if (distance >= radiusZoomingZone) {
113+
if (
114+
letterRef.style.fontVariationSettings !== fromFontVariationSettings
115+
) {
116+
letterRef.style.fontVariationSettings = fromFontVariationSettings;
117+
}
118+
return;
119+
}
120+
121+
const falloffValue = calculateFalloff(distance);
122+
123+
const newSettings = parsedSettings
124+
.map(({ axis, fromValue, toValue }) => {
125+
const interpolatedValue =
126+
fromValue + (toValue - fromValue) * falloffValue;
127+
return `'${axis}' ${interpolatedValue}`;
128+
})
129+
.join(", ");
130+
131+
interpolatedSettingsRef.current[index] = newSettings;
132+
letterRef.style.fontVariationSettings = newSettings;
133+
});
134+
});
135+
136+
const words = children.split(" ");
137+
let letterIndex = 0;
138+
139+
return (
140+
<span
141+
ref={ref}
142+
className={`${className} inline`}
143+
onClick={onClick}
144+
{...props}
145+
>
146+
{words.map((word, wordIndex) => (
147+
<span
148+
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
149+
key={`${wordIndex}-letter-effect`}
150+
className="inline-block whitespace-nowrap"
151+
>
152+
{word.split("").map((letter) => {
153+
const currentLetterIndex = letterIndex++;
154+
return (
155+
<motion.span
156+
key={currentLetterIndex}
157+
ref={(el: HTMLSpanElement | null) => {
158+
letterRefs.current[currentLetterIndex] = el;
159+
}}
160+
className="inline-block"
161+
aria-hidden="true"
162+
style={{
163+
fontVariationSettings:
164+
interpolatedSettingsRef.current[currentLetterIndex],
165+
}}
166+
>
167+
{letter}
168+
</motion.span>
169+
);
170+
})}
171+
{wordIndex < words.length - 1 && (
172+
<span className="inline-block">&nbsp;</span>
173+
)}
174+
</span>
175+
))}
176+
<span className="sr-only">{children}</span>
177+
</span>
178+
);
179+
};
180+
181+
VariableFontCursorProximity.displayName = "VariableFontCursorProximity";
182+
export default VariableFontCursorProximity;

packages/ui/section-list.ts

+23
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import marketing_ui_testimonials_category from "@/cuicui/marketing-ui/testimonia
7777
import other_creative_effects_category from "@/cuicui/other/creative-effects/category";
7878
import other_cursors_category from "@/cuicui/other/cursors/category";
7979
import other_equalizer_category from "@/cuicui/other/equalizer/category";
80+
import other_font_effects_category from "@/cuicui/other/font-effects/category";
8081
import other_mock_ups_category from "@/cuicui/other/mock-ups/category";
8182
import other_patterns_category from "@/cuicui/other/patterns/category";
8283
import other_qr_code_category from "@/cuicui/other/qr-code/category";
@@ -193,6 +194,7 @@ import other_creative_effects_wavy_line_component from "@/cuicui/other/creative-
193194
import other_cursors_dynamic_cards_component from "@/cuicui/other/cursors/dynamic-cards/component";
194195
import other_cursors_follow_cursor_component from "@/cuicui/other/cursors/follow-cursor/component";
195196
import other_equalizer_equalizer_component from "@/cuicui/other/equalizer/equalizer/component";
197+
import other_font_effects_variable_font_cursor_component from "@/cuicui/other/font-effects/variable-font-cursor/component";
196198
import other_mock_ups_airpods_component from "@/cuicui/other/mock-ups/airpods/component";
197199
import other_mock_ups_laptops_component from "@/cuicui/other/mock-ups/laptops/component";
198200
import other_mock_ups_smartphone_component from "@/cuicui/other/mock-ups/smartphone/component";
@@ -340,6 +342,7 @@ import other_cursors_dynamic_cards_only_border_card_effect_variant from "@/cuicu
340342
import other_cursors_follow_cursor_replace_cursor_variant from "@/cuicui/other/cursors/follow-cursor/replace-cursor.variant";
341343
import other_cursors_follow_cursor_with_cursor_variant from "@/cuicui/other/cursors/follow-cursor/with-cursor.variant";
342344
import other_equalizer_equalizer_equalizer_variant from "@/cuicui/other/equalizer/equalizer/equalizer.variant";
345+
import other_font_effects_variable_font_cursor_variable_font_cursor_variant from "@/cuicui/other/font-effects/variable-font-cursor/variable-font-cursor.variant";
343346
import other_mock_ups_airpods_airpods_pro_variant from "@/cuicui/other/mock-ups/airpods/airpods-pro.variant";
344347
import other_mock_ups_airpods_airpods_variant from "@/cuicui/other/mock-ups/airpods/airpods.variant";
345348
import other_mock_ups_laptops_mackbook_variant from "@/cuicui/other/mock-ups/laptops/mackbook.variant";
@@ -2505,6 +2508,26 @@ export const sectionList: SectionType[] = [
25052508
},
25062509
],
25072510
},
2511+
{
2512+
meta: other_font_effects_category,
2513+
slug: "font-effects",
2514+
components: [
2515+
{
2516+
meta: other_font_effects_variable_font_cursor_component,
2517+
slug: "variable-font-cursor",
2518+
variants: [
2519+
{
2520+
name: "variable-font-cursor",
2521+
variantComponent:
2522+
other_font_effects_variable_font_cursor_variable_font_cursor_variant,
2523+
slug: "variable-font-cursor",
2524+
pathname:
2525+
"cuicui/other/font-effects/variable-font-cursor/variable-font-cursor.variant.tsx",
2526+
},
2527+
],
2528+
},
2529+
],
2530+
},
25082531
{
25092532
meta: other_mock_ups_category,
25102533
slug: "mock-ups",

0 commit comments

Comments
 (0)