Skip to content

Commit 596fee0

Browse files
kylemclarenclaude
andcommitted
Add animated star border to search dialog
Wrap the search dialog with a StarBorder component that creates a subtle glowing border animation. The animation gracefully fades out when the user starts typing, keeping focus on the search results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent b19e8e7 commit 596fee0

File tree

4 files changed

+233
-137
lines changed

4 files changed

+233
-137
lines changed

components.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@
1818
"lib": "@/lib",
1919
"hooks": "@/hooks"
2020
},
21-
"registries": {}
21+
"registries": {
22+
"@react-bits": "https://reactbits.dev/r/{name}.json"
23+
}
2224
}

src/components/StarBorder.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type React from 'react';
2+
import { cn } from '@/lib/utils';
3+
4+
type StarBorderProps<T extends React.ElementType> =
5+
React.ComponentPropsWithoutRef<T> & {
6+
as?: T;
7+
className?: string;
8+
children?: React.ReactNode;
9+
color?: string;
10+
speed?: React.CSSProperties['animationDuration'];
11+
thickness?: number;
12+
isAnimating?: boolean;
13+
};
14+
15+
const StarBorder = <T extends React.ElementType = 'div'>({
16+
as,
17+
className = '',
18+
color = 'white',
19+
speed = '6s',
20+
thickness = 1,
21+
isAnimating = true,
22+
children,
23+
...rest
24+
}: StarBorderProps<T>) => {
25+
const Component = as || 'div';
26+
27+
return (
28+
<Component
29+
className={cn('relative inline-block overflow-hidden', className)}
30+
{...(rest as React.ComponentPropsWithoutRef<T>)}
31+
style={{
32+
padding: `${thickness}px 0`,
33+
...(rest as React.CSSProperties & { style?: React.CSSProperties })
34+
.style,
35+
}}
36+
>
37+
<div
38+
className={cn(
39+
'absolute w-[300%] h-[50%] bottom-[-11px] right-[-250%] rounded-full animate-star-movement-bottom z-0 transition-opacity duration-500',
40+
!isAnimating && 'opacity-0',
41+
)}
42+
style={{
43+
background: `radial-gradient(circle, ${color}, transparent 10%)`,
44+
animationDuration: speed,
45+
animationPlayState: isAnimating ? 'running' : 'paused',
46+
opacity: isAnimating ? 0.7 : 0,
47+
}}
48+
/>
49+
<div
50+
className={cn(
51+
'absolute w-[300%] h-[50%] top-[-10px] left-[-250%] rounded-full animate-star-movement-top z-0 transition-opacity duration-500',
52+
!isAnimating && 'opacity-0',
53+
)}
54+
style={{
55+
background: `radial-gradient(circle, ${color}, transparent 10%)`,
56+
animationDuration: speed,
57+
animationPlayState: isAnimating ? 'running' : 'paused',
58+
opacity: isAnimating ? 0.7 : 0,
59+
}}
60+
/>
61+
<div className="relative z-[1]">{children}</div>
62+
</Component>
63+
);
64+
};
65+
66+
export default StarBorder;

src/components/react/SearchDialog.tsx

Lines changed: 145 additions & 136 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 StarBorder from '@/components/StarBorder';
56
import { Kbd, KbdGroup } from '@/components/ui/kbd';
67
import { Spinner } from '@/components/ui/spinner';
78
import { cn } from '@/lib/utils';
@@ -476,150 +477,158 @@ export const SearchDialog: React.FC<SearchDialogProps> = ({
476477
/>
477478

478479
{/* Dialog */}
479-
<motion.div
480-
ref={dialogRef}
481-
initial={{ opacity: 0, scale: 0.96, y: -10 }}
482-
animate={{ opacity: 1, scale: 1, y: 0 }}
483-
exit={{ opacity: 0, scale: 0.96, y: -10 }}
484-
transition={{ duration: 0.15 }}
485-
className="relative w-full max-w-2xl bg-popover border border-border rounded-lg shadow-2xl overflow-hidden"
480+
<StarBorder
481+
className="w-full max-w-2xl rounded-lg"
482+
color="var(--primary)"
483+
speed="8s"
484+
thickness={1}
485+
isAnimating={!query}
486486
>
487-
{/* Search input */}
488-
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
489-
<svg
490-
className="w-5 h-5 text-muted-foreground flex-shrink-0"
491-
fill="none"
492-
viewBox="0 0 24 24"
493-
stroke="currentColor"
494-
>
495-
<path
496-
strokeLinecap="round"
497-
strokeLinejoin="round"
498-
strokeWidth={2}
499-
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
500-
/>
501-
</svg>
502-
<input
503-
ref={inputRef}
504-
type="text"
505-
value={query}
506-
onChange={(e) => setQuery(e.target.value)}
507-
placeholder={
508-
pagefindReady ? 'Search documentation...' : 'Loading search...'
509-
}
510-
disabled={!pagefindReady}
511-
className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground outline-none text-sm disabled:opacity-50"
512-
/>
513-
{query && (
514-
<button
515-
type="button"
516-
onClick={() => setQuery('')}
517-
className="text-muted-foreground hover:text-foreground transition-colors"
487+
<motion.div
488+
ref={dialogRef}
489+
initial={{ opacity: 0, scale: 0.96, y: -10 }}
490+
animate={{ opacity: 1, scale: 1, y: 0 }}
491+
exit={{ opacity: 0, scale: 0.96, y: -10 }}
492+
transition={{ duration: 0.15 }}
493+
className="relative w-full bg-popover border border-border rounded-lg shadow-2xl overflow-hidden"
494+
>
495+
{/* Search input */}
496+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
497+
<svg
498+
className="w-5 h-5 text-muted-foreground flex-shrink-0"
499+
fill="none"
500+
viewBox="0 0 24 24"
501+
stroke="currentColor"
518502
>
519-
<svg
520-
className="w-4 h-4"
521-
fill="none"
522-
viewBox="0 0 24 24"
523-
stroke="currentColor"
503+
<path
504+
strokeLinecap="round"
505+
strokeLinejoin="round"
506+
strokeWidth={2}
507+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
508+
/>
509+
</svg>
510+
<input
511+
ref={inputRef}
512+
type="text"
513+
value={query}
514+
onChange={(e) => setQuery(e.target.value)}
515+
placeholder={
516+
pagefindReady ? 'Search documentation...' : 'Loading search...'
517+
}
518+
disabled={!pagefindReady}
519+
className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground outline-none text-sm disabled:opacity-50"
520+
/>
521+
{query && (
522+
<button
523+
type="button"
524+
onClick={() => setQuery('')}
525+
className="text-muted-foreground hover:text-foreground transition-colors"
524526
>
525-
<path
526-
strokeLinecap="round"
527-
strokeLinejoin="round"
528-
strokeWidth={2}
529-
d="M6 18L18 6M6 6l12 12"
530-
/>
531-
</svg>
532-
</button>
533-
)}
534-
<Kbd className="hidden sm:inline-flex">Esc</Kbd>
535-
</div>
536-
537-
{/* Results count */}
538-
{!isLoading && query && results.length > 0 && (
539-
<div className="px-4 py-2 text-xs text-muted-foreground border-b border-border">
540-
{results.length} result{results.length !== 1 ? 's' : ''} for "
541-
{query}"
527+
<svg
528+
className="w-4 h-4"
529+
fill="none"
530+
viewBox="0 0 24 24"
531+
stroke="currentColor"
532+
>
533+
<path
534+
strokeLinecap="round"
535+
strokeLinejoin="round"
536+
strokeWidth={2}
537+
d="M6 18L18 6M6 6l12 12"
538+
/>
539+
</svg>
540+
</button>
541+
)}
542+
<Kbd className="hidden sm:inline-flex">Esc</Kbd>
542543
</div>
543-
)}
544544

545-
{/* Results - only show when there's a query */}
546-
{query && (
547-
<div className="relative">
548-
<div
549-
ref={listRef}
550-
className="max-h-[60vh] overflow-y-auto p-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-thumb]:rounded-full"
551-
onScroll={handleScroll}
552-
style={{
553-
scrollbarWidth: 'thin',
554-
scrollbarColor: 'var(--border) transparent',
555-
}}
556-
>
557-
{isLoading ? (
558-
<div className="py-8 flex items-center justify-center gap-2 text-muted-foreground text-sm">
559-
<Spinner className="size-4" />
560-
<span>Searching...</span>
561-
</div>
562-
) : selectableItems.length === 0 ? (
563-
<div className="py-8 text-center text-muted-foreground text-sm">
564-
No results found for "{query}"
565-
</div>
566-
) : (
567-
selectableItems.map((item, index) => (
568-
<AnimatedItem
569-
key={item.id}
570-
index={index}
571-
delay={0.03}
572-
onMouseEnter={() => setSelectedIndex(index)}
573-
onClick={() => {
574-
window.location.href = item.url;
575-
onClose();
576-
}}
577-
>
578-
{item.type === 'section' ? (
579-
<SectionResultItem
580-
item={item}
581-
isSelected={selectedIndex === index}
582-
/>
583-
) : (
584-
<PageResultItem
585-
item={item}
586-
isSelected={selectedIndex === index}
587-
/>
588-
)}
589-
</AnimatedItem>
590-
))
591-
)}
545+
{/* Results count */}
546+
{!isLoading && query && results.length > 0 && (
547+
<div className="px-4 py-2 text-xs text-muted-foreground border-b border-border">
548+
{results.length} result{results.length !== 1 ? 's' : ''} for "
549+
{query}"
592550
</div>
551+
)}
593552

594-
{/* Gradient overlays */}
595-
<div
596-
className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-popover to-transparent pointer-events-none transition-opacity duration-300"
597-
style={{ opacity: topGradientOpacity }}
598-
/>
599-
<div
600-
className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-popover to-transparent pointer-events-none transition-opacity duration-300"
601-
style={{ opacity: bottomGradientOpacity }}
602-
/>
603-
</div>
604-
)}
553+
{/* Results - only show when there's a query */}
554+
{query && (
555+
<div className="relative">
556+
<div
557+
ref={listRef}
558+
className="max-h-[60vh] overflow-y-auto p-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-thumb]:rounded-full"
559+
onScroll={handleScroll}
560+
style={{
561+
scrollbarWidth: 'thin',
562+
scrollbarColor: 'var(--border) transparent',
563+
}}
564+
>
565+
{isLoading ? (
566+
<div className="py-8 flex items-center justify-center gap-2 text-muted-foreground text-sm">
567+
<Spinner className="size-4" />
568+
<span>Searching...</span>
569+
</div>
570+
) : selectableItems.length === 0 ? (
571+
<div className="py-8 text-center text-muted-foreground text-sm">
572+
No results found for "{query}"
573+
</div>
574+
) : (
575+
selectableItems.map((item, index) => (
576+
<AnimatedItem
577+
key={item.id}
578+
index={index}
579+
delay={0.03}
580+
onMouseEnter={() => setSelectedIndex(index)}
581+
onClick={() => {
582+
window.location.href = item.url;
583+
onClose();
584+
}}
585+
>
586+
{item.type === 'section' ? (
587+
<SectionResultItem
588+
item={item}
589+
isSelected={selectedIndex === index}
590+
/>
591+
) : (
592+
<PageResultItem
593+
item={item}
594+
isSelected={selectedIndex === index}
595+
/>
596+
)}
597+
</AnimatedItem>
598+
))
599+
)}
600+
</div>
601+
602+
{/* Gradient overlays */}
603+
<div
604+
className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-popover to-transparent pointer-events-none transition-opacity duration-300"
605+
style={{ opacity: topGradientOpacity }}
606+
/>
607+
<div
608+
className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-popover to-transparent pointer-events-none transition-opacity duration-300"
609+
style={{ opacity: bottomGradientOpacity }}
610+
/>
611+
</div>
612+
)}
605613

606-
{/* Footer */}
607-
{selectableItems.length > 0 && (
608-
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground flex items-center justify-end gap-4">
609-
<span className="flex items-center gap-1">
610-
<KbdGroup>
611-
<Kbd></Kbd>
612-
<Kbd></Kbd>
613-
</KbdGroup>
614-
<span className="ml-1">Navigate</span>
615-
</span>
616-
<span className="flex items-center gap-1">
617-
<Kbd></Kbd>
618-
<span className="ml-1">Open</span>
619-
</span>
620-
</div>
621-
)}
622-
</motion.div>
614+
{/* Footer */}
615+
{selectableItems.length > 0 && (
616+
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground flex items-center justify-end gap-4">
617+
<span className="flex items-center gap-1">
618+
<KbdGroup>
619+
<Kbd></Kbd>
620+
<Kbd></Kbd>
621+
</KbdGroup>
622+
<span className="ml-1">Navigate</span>
623+
</span>
624+
<span className="flex items-center gap-1">
625+
<Kbd></Kbd>
626+
<span className="ml-1">Open</span>
627+
</span>
628+
</div>
629+
)}
630+
</motion.div>
631+
</StarBorder>
623632
</div>,
624633
document.body,
625634
);

0 commit comments

Comments
 (0)