Skip to content

Commit 493e41f

Browse files
authored
Add shiny text effect to search dialog placeholder (#29)
1 parent 4a5e806 commit 493e41f

File tree

5 files changed

+180
-18
lines changed

5 files changed

+180
-18
lines changed

astro.config.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,24 @@ export default defineConfig({
9090
},
9191
{
9292
tag: 'link',
93-
attrs: { rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' },
93+
attrs: {
94+
rel: 'icon',
95+
type: 'image/png',
96+
sizes: '96x96',
97+
href: '/favicon-96x96.png',
98+
},
9499
},
95100
{
96101
tag: 'link',
97102
attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
98103
},
99104
{
100105
tag: 'link',
101-
attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
106+
attrs: {
107+
rel: 'apple-touch-icon',
108+
sizes: '180x180',
109+
href: '/apple-touch-icon.png',
110+
},
102111
},
103112
{
104113
tag: 'link',

src/components/Search.astro

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,13 @@ import SearchDialogWrapper from './react/SearchDialogWrapper';
110110
.search-kbd {
111111
display: none;
112112
align-items: center;
113-
gap: 0.125rem;
113+
gap: 0.25em;
114114
padding: 0.125rem 0.375rem;
115115
margin-left: auto;
116116
font-size: 0.625rem;
117-
font-family: var(--sl-font-system-mono);
118-
color: var(--sl-color-gray-3);
119-
background: var(--sl-color-gray-5);
117+
font-family: var(--sl-font);
118+
color: var(--sl-color-text);
119+
background: var(--sl-color-gray-6);
120120
border-radius: 0.25rem;
121121
}
122122

src/components/ShinyText.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
motion,
3+
useAnimationFrame,
4+
useMotionValue,
5+
useTransform,
6+
} from 'motion/react';
7+
import type React from 'react';
8+
import { useCallback, useEffect, useRef, useState } from 'react';
9+
10+
interface ShinyTextProps {
11+
text: string;
12+
disabled?: boolean;
13+
speed?: number;
14+
className?: string;
15+
color?: string;
16+
shineColor?: string;
17+
spread?: number;
18+
yoyo?: boolean;
19+
pauseOnHover?: boolean;
20+
direction?: 'left' | 'right';
21+
delay?: number;
22+
}
23+
24+
const ShinyText: React.FC<ShinyTextProps> = ({
25+
text,
26+
disabled = false,
27+
speed = 2,
28+
className = '',
29+
color = '#b5b5b5',
30+
shineColor = '#ffffff',
31+
spread = 120,
32+
yoyo = false,
33+
pauseOnHover = false,
34+
direction = 'left',
35+
delay = 0,
36+
}) => {
37+
const [isPaused, setIsPaused] = useState(false);
38+
const progress = useMotionValue(0);
39+
const elapsedRef = useRef(0);
40+
const lastTimeRef = useRef<number | null>(null);
41+
const directionRef = useRef(direction === 'left' ? 1 : -1);
42+
43+
const animationDuration = speed * 1000;
44+
const delayDuration = delay * 1000;
45+
46+
useAnimationFrame((time) => {
47+
if (disabled || isPaused) {
48+
lastTimeRef.current = null;
49+
return;
50+
}
51+
52+
if (lastTimeRef.current === null) {
53+
lastTimeRef.current = time;
54+
return;
55+
}
56+
57+
const deltaTime = time - lastTimeRef.current;
58+
lastTimeRef.current = time;
59+
60+
elapsedRef.current += deltaTime;
61+
62+
// Animation goes from 0 to 100
63+
if (yoyo) {
64+
const cycleDuration = animationDuration + delayDuration;
65+
const fullCycle = cycleDuration * 2;
66+
const cycleTime = elapsedRef.current % fullCycle;
67+
68+
if (cycleTime < animationDuration) {
69+
// Forward animation: 0 -> 100
70+
const p = (cycleTime / animationDuration) * 100;
71+
progress.set(directionRef.current === 1 ? p : 100 - p);
72+
} else if (cycleTime < cycleDuration) {
73+
// Delay at end
74+
progress.set(directionRef.current === 1 ? 100 : 0);
75+
} else if (cycleTime < cycleDuration + animationDuration) {
76+
// Reverse animation: 100 -> 0
77+
const reverseTime = cycleTime - cycleDuration;
78+
const p = 100 - (reverseTime / animationDuration) * 100;
79+
progress.set(directionRef.current === 1 ? p : 100 - p);
80+
} else {
81+
// Delay at start
82+
progress.set(directionRef.current === 1 ? 0 : 100);
83+
}
84+
} else {
85+
const cycleDuration = animationDuration + delayDuration;
86+
const cycleTime = elapsedRef.current % cycleDuration;
87+
88+
if (cycleTime < animationDuration) {
89+
// Animation phase: 0 -> 100
90+
const p = (cycleTime / animationDuration) * 100;
91+
progress.set(directionRef.current === 1 ? p : 100 - p);
92+
} else {
93+
// Delay phase - hold at end (shine off-screen)
94+
progress.set(directionRef.current === 1 ? 100 : 0);
95+
}
96+
}
97+
});
98+
99+
// biome-ignore lint/correctness/useExhaustiveDependencies: progress.set is stable
100+
useEffect(() => {
101+
directionRef.current = direction === 'left' ? 1 : -1;
102+
elapsedRef.current = 0;
103+
progress.set(0);
104+
}, [direction]);
105+
106+
// Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
107+
const backgroundPosition = useTransform(
108+
progress,
109+
(p) => `${150 - p * 2}% center`,
110+
);
111+
112+
const handleMouseEnter = useCallback(() => {
113+
if (pauseOnHover) setIsPaused(true);
114+
}, [pauseOnHover]);
115+
116+
const handleMouseLeave = useCallback(() => {
117+
if (pauseOnHover) setIsPaused(false);
118+
}, [pauseOnHover]);
119+
120+
const gradientStyle: React.CSSProperties = {
121+
backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
122+
backgroundSize: '200% auto',
123+
WebkitBackgroundClip: 'text',
124+
backgroundClip: 'text',
125+
WebkitTextFillColor: 'transparent',
126+
};
127+
128+
return (
129+
<motion.span
130+
className={`inline-block ${className}`}
131+
style={{ ...gradientStyle, backgroundPosition }}
132+
onMouseEnter={handleMouseEnter}
133+
onMouseLeave={handleMouseLeave}
134+
>
135+
{text}
136+
</motion.span>
137+
);
138+
};
139+
140+
export default ShinyText;

src/components/react/SearchDialog.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { motion } from 'motion/react';
22
import type React from 'react';
33
import { useEffect, useMemo, useRef, useState } from 'react';
44
import { createPortal } from 'react-dom';
5+
import ShinyText from '@/components/ShinyText';
56
import StarBorder from '@/components/StarBorder';
67
import { Kbd, KbdGroup } from '@/components/ui/kbd';
78
import { Spinner } from '@/components/ui/spinner';
@@ -482,7 +483,7 @@ export const SearchDialog: React.FC<SearchDialogProps> = ({
482483
color="var(--primary)"
483484
speed="8s"
484485
thickness={1}
485-
isAnimating={!query}
486+
isAnimating={false}
486487
>
487488
<motion.div
488489
ref={dialogRef}
@@ -512,17 +513,28 @@ export const SearchDialog: React.FC<SearchDialogProps> = ({
512513
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
513514
/>
514515
</svg>
515-
<input
516-
ref={inputRef}
517-
type="text"
518-
value={query}
519-
onChange={(e) => setQuery(e.target.value)}
520-
placeholder={
521-
pagefindReady ? 'Search documentation...' : 'Loading search...'
522-
}
523-
disabled={!pagefindReady}
524-
className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground outline-none text-sm disabled:opacity-50"
525-
/>
516+
<div className="relative flex-1">
517+
<input
518+
ref={inputRef}
519+
type="text"
520+
value={query}
521+
onChange={(e) => setQuery(e.target.value)}
522+
placeholder={!pagefindReady ? 'Loading search...' : ''}
523+
disabled={!pagefindReady}
524+
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground outline-none text-sm disabled:opacity-50"
525+
/>
526+
{pagefindReady && !query && (
527+
<div className="absolute inset-0 flex items-center pointer-events-none">
528+
<ShinyText
529+
text="Search documentation..."
530+
color="var(--muted-foreground)"
531+
shineColor="var(--foreground)"
532+
speed={3}
533+
className="text-sm"
534+
/>
535+
</div>
536+
)}
537+
</div>
526538
{query && (
527539
<button
528540
type="button"

src/components/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// React island components for Astro
22
// Use with client:load directive in MDX files
33

4+
export { default as ShinyText } from '../ShinyText';
45
export {
56
AnimatedItem,
67
AnimatedList,

0 commit comments

Comments
 (0)