apex-ui-kit 2.0.7 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.jsx +74 -53
  2. package/dist/ApexUI-Kit/templates/BeamBar/BeamBar.jsx +5 -3
  3. package/dist/ApexUI-Kit/templates/BluePrintBackground/BluePrintBackground.jsx +14 -2
  4. package/dist/ApexUI-Kit/templates/CardStack/CardStack.jsx +56 -28
  5. package/dist/ApexUI-Kit/templates/Cards/BasicCard.jsx +3 -3
  6. package/dist/ApexUI-Kit/templates/DragCarousel/DragCarousel.jsx +1 -1
  7. package/dist/ApexUI-Kit/templates/EdgeGlowForm/EdgeGlowFrom.jsx +116 -98
  8. package/dist/ApexUI-Kit/templates/FlipProfile/FlipProfile.jsx +69 -37
  9. package/dist/ApexUI-Kit/templates/GlareCard/GlareCard.jsx +2 -2
  10. package/dist/ApexUI-Kit/templates/HoverText/HoverText.jsx +14 -13
  11. package/dist/ApexUI-Kit/templates/HyperCard/HyperCard.jsx +59 -42
  12. package/dist/ApexUI-Kit/templates/KineticThreadsBackground/KineticThreadBackground.jsx +17 -2
  13. package/dist/ApexUI-Kit/templates/LensFlareBackground/LensFlareBackground.jsx +18 -3
  14. package/dist/ApexUI-Kit/templates/LuminousParticleOcean/LuminousParticleOcean.jsx +15 -1
  15. package/dist/ApexUI-Kit/templates/MaskView/MaskView.jsx +345 -0
  16. package/dist/ApexUI-Kit/templates/MeteorCard/MeteorCard.jsx +34 -45
  17. package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +272 -101
  18. package/dist/ApexUI-Kit/templates/NavMenu/NavMenu.jsx +8 -2
  19. package/dist/ApexUI-Kit/templates/ParallaxCard/ParallaxCard.jsx +13 -11
  20. package/dist/ApexUI-Kit/templates/ParallaxCarousel/ParallaxCarousel.jsx +9 -9
  21. package/dist/ApexUI-Kit/templates/ParallaxProfile/ParallaxProfile.jsx +41 -38
  22. package/dist/ApexUI-Kit/templates/PointerFollower/PointerFollower.jsx +7 -7
  23. package/dist/ApexUI-Kit/templates/SmartGridCard/SmartGridCard.jsx +4 -3
  24. package/dist/ApexUI-Kit/templates/SmokeBackground/SmokeBackground.jsx +13 -2
  25. package/dist/ApexUI-Kit/templates/Testimonials/Testimonials.jsx +11 -8
  26. package/dist/ApexUI-Kit/templates/ThemeToggle/ThemeAnimation.js +40 -69
  27. package/dist/ApexUI-Kit/templates/ThemeToggle/ThemeToggle.css +1 -1
  28. package/dist/ApexUI-Kit/templates/ThemeToggle/ThemeToggle.jsx +25 -7
  29. package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +71 -52
  30. package/dist/cli/index.js +24 -13
  31. package/package.json +2 -2
  32. package/dist/ApexUI-Kit/templates/WaterDropReveal/WaterDropReveal.jsx +0 -151
@@ -7,7 +7,7 @@ const LinkedInIcon = () => (<svg width="24" height="24" fill="none" stroke="curr
7
7
  const TwitterIcon = () => (<BsTwitterX size={24} />);
8
8
  const InstagramIcon = () => (<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1112.63 8 4 4 0 0116 11.37z" /><line x1="17.5" y1="6.5" x2="17.5" y2="6.5" /></svg>);
9
9
  const defaultProfile = {
10
- avatarUrl: "/assets/spiderman.png",
10
+ avatarUrl: "https://ik.imagekit.io/ny6yinyut/apexui/assets/spiderman.png?tr=f-auto,q-80,w-480",
11
11
  name: "Spider Man",
12
12
  username: "spider_man",
13
13
  dob: "10-08-2001",
@@ -44,7 +44,7 @@ const ParallaxProfileCard = ({ profile }) => {
44
44
  gsap.to(card, { duration: 1, rotationX: 0, rotationY: 0, scale: 1, ease: "elastic.out(1, 0.3)" });
45
45
  };
46
46
  gsap.set(card, { transformPerspective: 1000 });
47
- card.addEventListener("mousemove", handleMouseMove);
47
+ card.addEventListener("mousemove", handleMouseMove, { passive: true });
48
48
  card.addEventListener("mouseleave", handleMouseLeave);
49
49
  return () => {
50
50
  card.removeEventListener("mousemove", handleMouseMove);
@@ -52,51 +52,54 @@ const ParallaxProfileCard = ({ profile }) => {
52
52
  };
53
53
  }, []);
54
54
 
55
+ const iconBtn = "flex h-9 w-9 items-center justify-center rounded-full border border-cyan-400/20 bg-cyan-400/5 text-cyan-300 transition-colors duration-200 hover:border-cyan-300/60 hover:text-white [&_svg]:h-[18px] [&_svg]:w-[18px]";
56
+
55
57
  return (
56
58
  <div
57
59
  ref={cardRef}
58
- className="relative w-[300px] h-[440px] bg-[#1D2B3A] rounded-3xl p-6 border border-cyan-400/30 shadow-2xl shadow-cyan-900/40 flex flex-col"
60
+ className="relative w-[300px] max-w-full bg-[#1D2B3A] rounded-3xl p-6 border border-cyan-400/30 shadow-2xl shadow-cyan-900/40"
59
61
  style={{ transformStyle: "preserve-3d" }}
60
62
  >
61
- <div className="absolute inset-0 bg-gradient-to-br from-[#1D2B3A] to-[#111827] rounded-3xl"></div>
62
- <div className="absolute top-0 left-0 w-full h-full" style={{ background: "radial-gradient(circle at 50% 50%, rgba(34, 211, 238, 0.08), transparent 70%)", transform: "translateZ(20px)" }}></div>
63
+ <div className="absolute inset-0 bg-gradient-to-br from-[#1D2B3A] to-[#0b1220] rounded-3xl"></div>
64
+ <div className="absolute inset-x-0 top-0 h-28 rounded-t-3xl" style={{ background: "radial-gradient(120% 100% at 50% 0%, rgba(34, 211, 238, 0.22), transparent 70%)", transform: "translateZ(20px)" }}></div>
63
65
 
64
- <div className="relative z-10 flex flex-col items-center justify-between h-full" style={{ transform: "translateZ(50px)" }}>
65
- {/* Top Part */}
66
- <div className="text-center">
67
- <img src={avatarUrl} alt="Profile" className="w-28 h-28 rounded-full mx-auto mb-3 border-4 border-cyan-400 shadow-lg object-cover" onError={(e) => { e.target.onerror = null; e.target.src = "https://placehold.co/128x128/1D2B3A/A5F3FC?text=ERR"; }} />
68
- <h2 className="text-2xl font-bold text-cyan-300 tracking-wide">{name}</h2>
69
- <div className="flex flex-col items-center mt-1">
70
- <span className="text-xs font-semibold text-slate-300 bg-slate-700 px-2 py-0.5 rounded-full mb-1">@{username}</span>
71
- <span className="text-xs font-medium text-cyan-400 bg-slate-700 px-2 py-0.5 rounded-full">DOB: {dob}</span>
72
- </div>
66
+ <div className="relative z-10 flex flex-col items-center text-center" style={{ transform: "translateZ(50px)" }}>
67
+ {/* Avatar with status dot */}
68
+ <div className="relative">
69
+ <img src={avatarUrl} alt="Profile" className="w-24 h-24 rounded-full border-[3px] border-cyan-400 shadow-lg shadow-cyan-500/30 object-cover" onError={(e) => { e.target.onerror = null; e.target.src = "https://placehold.co/128x128/1D2B3A/A5F3FC?text=ERR"; }} />
70
+ {status && <span className="absolute bottom-1 right-1 h-4 w-4 rounded-full border-[3px] border-[#1D2B3A] bg-green-500" />}
71
+ </div>
72
+ <h2 className="mt-3 text-2xl font-bold text-cyan-300 tracking-wide">{name}</h2>
73
+ <p className="text-sm text-slate-400">{title}</p>
74
+ <div className="mt-3 flex flex-wrap items-center justify-center gap-1.5">
75
+ <span className="text-[11px] font-semibold text-slate-300 bg-slate-700/70 px-2.5 py-1 rounded-full">@{username}</span>
76
+ <span className="text-[11px] font-medium text-cyan-300 bg-slate-700/70 px-2.5 py-1 rounded-full">DOB: {dob}</span>
73
77
  </div>
74
78
 
75
- {/* Bottom Part */}
76
- <div className="w-full text-center">
77
- <p className="text-slate-400 text-lg">{title}</p>
78
- {status && (
79
- <div className="flex items-center justify-center gap-2 mt-2">
80
- <span className="relative flex h-3 w-3"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span><span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span></span>
81
- <span className="text-green-300 text-sm">{status}</span>
82
- </div>
83
- )}
84
- <div className="flex justify-center gap-4 my-4">
85
- {socialLinks?.github && (<a href={socialLinks.github} target="_blank" rel="noopener noreferrer" title="GitHub" className="text-cyan-400 hover:text-white transition-colors duration-200"><GitHubIcon /></a>)}
86
- {socialLinks?.linkedin && (<a href={socialLinks.linkedin} target="_blank" rel="noopener noreferrer" title="LinkedIn" className="text-cyan-400 hover:text-white transition-colors duration-200"><LinkedInIcon /></a>)}
87
- {socialLinks?.twitter && (<a href={socialLinks.twitter} target="_blank" rel="noopener noreferrer" title="Twitter" className="text-cyan-400 hover:text-white transition-colors duration-200"><TwitterIcon /></a>)}
88
- {socialLinks?.instagram && (<a href={socialLinks.instagram} target="_blank" rel="noopener noreferrer" title="Instagram" className="text-cyan-400 hover:text-white transition-colors duration-200"><InstagramIcon /></a>)}
79
+ {status && (
80
+ <div className="mt-4 inline-flex items-center gap-2 rounded-full border border-green-400/20 bg-green-400/5 px-3 py-1">
81
+ <span className="relative flex h-2 w-2"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span><span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span></span>
82
+ <span className="text-green-300 text-xs font-medium">{status}</span>
89
83
  </div>
84
+ )}
90
85
 
91
- {/* Buttons Section */}
92
- <div className="flex justify-center gap-3">
93
- <button onClick={onHireClick} className="w-1/2 bg-cyan-500 text-slate-900 font-bold py-2 px-4 rounded-lg hover:bg-cyan-400 transition-colors duration-300">
94
- Hire Me
95
- </button>
96
- <button onClick={onCvClick} className="w-1/2 bg-slate-700 text-cyan-300 font-bold py-2 px-4 rounded-lg hover:bg-slate-600 transition-colors duration-300">
97
- CV
98
- </button>
99
- </div>
86
+ <div className="my-5 h-px w-full bg-gradient-to-r from-transparent via-cyan-400/25 to-transparent" />
87
+
88
+ <div className="flex justify-center gap-2.5">
89
+ {socialLinks?.github && (<a href={socialLinks.github} target="_blank" rel="noopener noreferrer" title="GitHub" className={iconBtn}><GitHubIcon /></a>)}
90
+ {socialLinks?.linkedin && (<a href={socialLinks.linkedin} target="_blank" rel="noopener noreferrer" title="LinkedIn" className={iconBtn}><LinkedInIcon /></a>)}
91
+ {socialLinks?.twitter && (<a href={socialLinks.twitter} target="_blank" rel="noopener noreferrer" title="Twitter" className={iconBtn}><TwitterIcon /></a>)}
92
+ {socialLinks?.instagram && (<a href={socialLinks.instagram} target="_blank" rel="noopener noreferrer" title="Instagram" className={iconBtn}><InstagramIcon /></a>)}
93
+ </div>
94
+
95
+ {/* Buttons Section */}
96
+ <div className="mt-5 flex w-full gap-3">
97
+ <button onClick={onHireClick} className="flex-1 whitespace-nowrap bg-cyan-500 text-slate-900 text-sm font-bold py-2.5 px-3 rounded-xl hover:bg-cyan-400 transition-colors duration-300">
98
+ Hire Me
99
+ </button>
100
+ <button onClick={onCvClick} className="flex-1 whitespace-nowrap bg-slate-700/80 text-cyan-300 text-sm font-bold py-2.5 px-3 rounded-xl hover:bg-slate-600 transition-colors duration-300">
101
+ Download CV
102
+ </button>
100
103
  </div>
101
104
  </div>
102
105
  </div>
@@ -106,7 +109,7 @@ const ParallaxProfileCard = ({ profile }) => {
106
109
  const ParallaxProfile = ({ profiles = [defaultProfile], className = '' }) => {
107
110
  const safe = Array.isArray(profiles) && profiles.length > 0 ? profiles : [defaultProfile];
108
111
  return (
109
- <div className={`flex flex-wrap gap-6 justify-center ${className}`}>
112
+ <div className={`flex w-full flex-wrap gap-6 justify-center ${className}`}>
110
113
  {safe.map((p, i) => (
111
114
  <ParallaxProfileCard key={p.id || p.username || i} profile={p} />
112
115
  ))}
@@ -1,12 +1,12 @@
1
1
  import React, { useRef, useEffect, useState } from "react";
2
2
 
3
3
  const defaultPeople = [
4
- { id: 1, name: "Captain America", job: "Leader of the Avengers", img: "/assets/captainamerica.png" },
5
- { id: 2, name: "Doctor Strange", job: "Sorcerer Supreme", img: "/assets/doctorStrange.png" },
6
- { id: 3, name: "Iron Man", job: "Leader Of Stark Industries", img: "/assets/ironman.png" },
7
- { id: 4, name: "HULK", job: "Scientist", img: "/assets/hulk.png" },
8
- { id: 5, name: "Spider-Man", job: "Friendly Neighborhood Spider-Man", img: "/assets/spiderman.png" },
9
- { id: 6, name: "Thanos", job: "The Mad Titan", img: "/assets/thanos.png" },
4
+ { id: 1, name: "Captain America", job: "Leader of the Avengers", img: "https://ik.imagekit.io/ny6yinyut/apexui/assets/captainamerica.png?tr=f-auto,q-80,w-480" },
5
+ { id: 2, name: "Doctor Strange", job: "Sorcerer Supreme", img: "https://ik.imagekit.io/ny6yinyut/apexui/assets/doctorStrange.png?tr=f-auto,q-80,w-480" },
6
+ { id: 3, name: "Iron Man", job: "Leader Of Stark Industries", img: "https://ik.imagekit.io/ny6yinyut/apexui/assets/ironman.png?tr=f-auto,q-80,w-480" },
7
+ { id: 4, name: "HULK", job: "Scientist", img: "https://ik.imagekit.io/ny6yinyut/apexui/assets/hulk.png?tr=f-auto,q-80,w-480" },
8
+ { id: 5, name: "Spider-Man", job: "Friendly Neighborhood Spider-Man", img: "https://ik.imagekit.io/ny6yinyut/apexui/assets/spiderman.png?tr=f-auto,q-80,w-480" },
9
+ { id: 6, name: "Thanos", job: "The Mad Titan", img: "https://ik.imagekit.io/ny6yinyut/apexui/assets/thanos.png?tr=f-auto,q-80,w-480" },
10
10
  ];
11
11
 
12
12
  const PointerFollower = ({
@@ -53,7 +53,7 @@ const PointerFollower = ({
53
53
  setHovered(false);
54
54
  };
55
55
 
56
- container.addEventListener("pointermove", handleMove);
56
+ container.addEventListener("pointermove", handleMove, { passive: true });
57
57
  container.addEventListener("pointerleave", handleLeave);
58
58
 
59
59
  return () => {
@@ -123,10 +123,11 @@ const SmartGridCard = ({
123
123
  <div
124
124
  className={`grid gap-2 justify-center p-4 w-full ${screenWidth >= 1024 ? 'grid-cols-3' : screenWidth >= 768 ? 'grid-cols-2' : 'grid-cols-1'}`}
125
125
  style={screenWidth >= 1024 ? {
126
- gridTemplateColumns: "repeat(3, minmax(300px, 1fr))",
126
+ // minmax(0, …): columns share the container's width instead of overflowing it.
127
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
127
128
  gridTemplateRows: `repeat(2, ${CARD_HEIGHT}px)`
128
129
  } : screenWidth >= 768 ? {
129
- gridTemplateColumns: "repeat(2, minmax(300px, 1fr))",
130
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
130
131
  gridTemplateRows: `repeat(3, ${CARD_HEIGHT}px)`
131
132
  } : {}}
132
133
  >
@@ -164,7 +165,7 @@ const SmartGridCard = ({
164
165
  </div>
165
166
  <button
166
167
  type="button"
167
- className="mt-auto relative overflow-hidden px-5 py-3 rounded-lg border border-purple-400/60 text-white transition hover:text-white bg-black/20 group flex items-center gap-2 text-xs md:text-base"
168
+ className="mt-auto relative overflow-hidden whitespace-nowrap px-4 py-3 rounded-lg border border-purple-400/60 text-white transition hover:text-white bg-black/20 group flex items-center gap-2 text-xs md:text-base"
168
169
  >
169
170
  <span className="relative z-10 flex items-center gap-2">
170
171
  {card.buttonText}
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useRef } from "react";
2
- import { Renderer, Program, Mesh, Triangle } from "https://unpkg.com/ogl";
2
+ import { Renderer, Program, Mesh, Triangle } from "ogl";
3
3
 
4
4
  const hexToRgb = (hex) => {
5
5
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
@@ -158,7 +158,7 @@ export const SmokeBackground = ({
158
158
  mouseNeedsUpdate = true;
159
159
  };
160
160
  if (mouseInteractive) {
161
- mountNode.addEventListener("mousemove", handleMouseMove);
161
+ mountNode.addEventListener("mousemove", handleMouseMove, { passive: true });
162
162
  }
163
163
 
164
164
  const setSize = () => {
@@ -178,8 +178,12 @@ export const SmokeBackground = ({
178
178
  setSize();
179
179
 
180
180
  let raf = 0;
181
+ // Only render while the background is on screen.
182
+ let visible = true;
181
183
  const t0 = performance.now();
182
184
  const loop = (t) => {
185
+ raf = 0;
186
+ if (!visible) return;
183
187
  let timeValue = (t - t0) * 0.001;
184
188
 
185
189
  if (direction === "funny") {
@@ -199,9 +203,15 @@ export const SmokeBackground = ({
199
203
  raf = requestAnimationFrame(loop);
200
204
  };
201
205
  raf = requestAnimationFrame(loop);
206
+ const visibilityObserver = new IntersectionObserver(([entry]) => {
207
+ visible = entry.isIntersecting;
208
+ if (visible && !raf) raf = requestAnimationFrame(loop);
209
+ });
210
+ visibilityObserver.observe(mountNode);
202
211
 
203
212
  return () => {
204
213
  cancelAnimationFrame(raf);
214
+ visibilityObserver.disconnect();
205
215
  ro.disconnect();
206
216
  if (mouseInteractive && mountNode) {
207
217
  mountNode.removeEventListener("mousemove", handleMouseMove);
@@ -209,6 +219,7 @@ export const SmokeBackground = ({
209
219
  try {
210
220
  mountNode.removeChild(canvas);
211
221
  } catch (error) { console.error("Error removing canvas:", error); }
222
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
212
223
  };
213
224
  }, [color, speed, direction, scale, opacity, mouseInteractive, className]);
214
225
 
@@ -24,12 +24,12 @@ const Testimonials = ({ testimonials }) => {
24
24
  };
25
25
 
26
26
  return (
27
- <div className="w-full max-w-4xl mx-auto bg-slate-950 rounded-2xl p-8 md:p-12 border border-slate-800 grid md:grid-cols-2 gap-12 items-center overflow-hidden">
27
+ <div className="w-full max-w-4xl mx-auto bg-slate-950 rounded-2xl p-6 sm:p-8 md:p-12 border border-slate-800 grid md:grid-cols-2 gap-8 md:gap-12 items-center overflow-hidden">
28
28
 
29
29
  {/* Left Part: Text Content and Navigation */}
30
30
  <div className="flex flex-col justify-center h-full text-center md:text-left order-2 md:order-1">
31
- {/* Title and Subtitle with Animation */}
32
- <div className="relative h-32">
31
+ {/* Title, subtitle and quote with animation; the min height keeps the buttons from jumping. */}
32
+ <div className="relative min-h-[13rem] sm:min-h-[12rem]">
33
33
  <AnimatePresence initial={false} mode="wait">
34
34
  <motion.div
35
35
  key={activeIndex}
@@ -38,11 +38,11 @@ const Testimonials = ({ testimonials }) => {
38
38
  animate="center"
39
39
  exit="exit"
40
40
  transition={{ duration: 0.4, ease: "easeInOut" }}
41
- className="absolute w-full"
41
+ className="w-full"
42
42
  >
43
- <h2 className="text-3xl font-bold text-white tracking-tight">{testimonials[activeIndex].author}</h2>
43
+ <h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{testimonials[activeIndex].author}</h2>
44
44
  <p className="mt-2 text-lime-400 font-medium">{testimonials[activeIndex].title}</p>
45
- <p className="mt-4 text-slate-400 text-sm leading-relaxed hidden md:block">"{testimonials[activeIndex].text}"</p>
45
+ <p className="mt-4 text-slate-400 text-sm leading-relaxed">"{testimonials[activeIndex].text}"</p>
46
46
  </motion.div>
47
47
  </AnimatePresence>
48
48
  </div>
@@ -71,7 +71,9 @@ const Testimonials = ({ testimonials }) => {
71
71
  </div>
72
72
 
73
73
  {/* Right Part: Stacked Images */}
74
- <div className="relative h-80 w-full flex items-center justify-center order-1 md:order-2">
74
+ <div className="flex w-full items-center justify-center order-1 md:order-2">
75
+ {/* Sized to the card plus the fan, so the stack stays centred and inside the panel. */}
76
+ <div className="relative h-[calc(16rem+30px)] w-[calc(12rem+60px)] sm:h-[calc(20rem+30px)] sm:w-[calc(16rem+60px)]">
75
77
  {testimonials.map((testimonial, index) => {
76
78
  // Determine the position of the card in the stack
77
79
  const position = (index - activeIndex + testimonials.length) % testimonials.length;
@@ -82,7 +84,7 @@ const Testimonials = ({ testimonials }) => {
82
84
  return (
83
85
  <motion.div
84
86
  key={testimonial.id || index}
85
- className="absolute w-64 h-80 bg-slate-800 rounded-xl shadow-2xl overflow-hidden border-2 border-slate-700"
87
+ className="absolute w-48 h-64 sm:w-64 sm:h-80 bg-slate-800 rounded-xl shadow-2xl overflow-hidden border-2 border-slate-700"
86
88
  initial={false}
87
89
  animate={{
88
90
  scale: 1 - position * 0.08,
@@ -107,6 +109,7 @@ const Testimonials = ({ testimonials }) => {
107
109
  </motion.div>
108
110
  );
109
111
  })}
112
+ </div>
110
113
  </div>
111
114
  </div>
112
115
  );
@@ -1,14 +1,42 @@
1
1
  // config/animations.js
2
2
 
3
- // Small utility to escape quotes in data-URIs
4
- const esc = (s = "") => s.replace(/#/g, "%23").replace(/\n/g, "");
3
+ // Soft-edged circle that grows from `at` until it covers the screen. It animates
4
+ // a radial-gradient mask through a registered property, which stays smooth;
5
+ // scaling a blurred SVG mask to screen size re-rasterises every frame and flickers.
6
+ const circleReveal = (at) => `
7
+ @property --vt-r { syntax: '<length>'; inherits: false; initial-value: 0px; }
8
+ ::view-transition-new(root) {
9
+ -webkit-mask-image: radial-gradient(circle at ${at}, #000 var(--vt-r), transparent calc(var(--vt-r) + 12vmax));
10
+ mask-image: radial-gradient(circle at ${at}, #000 var(--vt-r), transparent calc(var(--vt-r) + 12vmax));
11
+ animation: vt-circle var(--vt-duration) var(--vt-ease);
12
+ }
13
+ ::view-transition-old(root) { animation: none; }
14
+ @keyframes vt-circle {
15
+ from { --vt-r: -12vmax; }
16
+ to { --vt-r: 150vmax; }
17
+ }
18
+ `;
19
+
20
+ // Holds the last frame until the transition ends (no flash of the old theme)
21
+ // and keeps the browser's default plus-lighter blending out of the reveal.
22
+ const footer = `
23
+ ::view-transition-old(root), ::view-transition-new(root) {
24
+ animation-fill-mode: both;
25
+ mix-blend-mode: normal;
26
+ }
27
+ `;
5
28
 
6
29
  export function buildAnimationCSS(animation, opts = {}) {
30
+ return presetCSS(animation, opts) + footer;
31
+ }
32
+
33
+ function presetCSS(animation, opts = {}) {
7
34
  const duration = opts.duration || "1.5s";
8
35
  const ease = opts.ease || "var(--vt-ease)";
9
36
 
37
+ // `var(--vt-ease)` would refer to itself here, so only set a real easing.
10
38
  const header = `
11
- :root { --vt-duration: ${duration}; --vt-ease: ${ease}; }
39
+ :root { --vt-duration: ${duration};${ease === "var(--vt-ease)" ? "" : ` --vt-ease: ${ease};`} }
12
40
  ::view-transition-group(root){ animation-timing-function: var(--vt-ease); }
13
41
  `;
14
42
 
@@ -37,72 +65,15 @@ export function buildAnimationCSS(animation, opts = {}) {
37
65
  const preset = typeof animation === "string" ? animation : animation?.type;
38
66
 
39
67
  switch (preset) {
40
- case "circle": {
41
- const circleSVG = esc(
42
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="white"/></svg>`
43
- );
44
- return header + `
45
- ::view-transition-new(root) {
46
- mask: url('data:image/svg+xml,${circleSVG}') center / 0 no-repeat;
47
- -webkit-mask: url('data:image/svg+xml,${circleSVG}') center / 0 no-repeat;
48
- animation: vt-scale var(--vt-duration);
49
- }
50
- ::view-transition-old(root), .dark::view-transition-old(root) {
51
- animation: vt-scale var(--vt-duration);
52
- }
53
- @keyframes vt-scale {
54
- to { mask-size: 200vmax; -webkit-mask-size: 200vmax; }
55
- }
56
- `;
57
- }
68
+ case "circle":
69
+ // Grows from the clicked button (ThemeToggle sets --cx / --cy).
70
+ return header + circleReveal("var(--cx, 50%) var(--cy, 50%)");
58
71
 
59
- case "circle-left": {
60
- const svg = esc(
61
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="b"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="0" cy="0" r="18" fill="white" filter="url(%23b)"/></svg>`
62
- );
63
- return header + `
64
- ::view-transition-new(root) {
65
- mask: url('data:image/svg+xml,${svg}') top left / 0 no-repeat;
66
- -webkit-mask: url('data:image/svg+xml,${svg}') top left / 0 no-repeat;
67
- transform-origin: top left;
68
- animation: vt-scale-tl var(--vt-duration);
69
- }
70
- ::view-transition-old(root), .dark::view-transition-old(root) {
71
- transform-origin: top left;
72
- animation: vt-scale-tl var(--vt-duration);
73
- }
74
- @keyframes vt-scale-tl { to { mask-size: 350vmax; -webkit-mask-size: 350vmax; } }
75
- `;
76
- }
77
- case "circle-right": {
78
- const svg = esc(
79
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
80
- <defs><filter id="b"><feGaussianBlur stdDeviation="2"/></filter></defs>
81
- <circle cx="40" cy="0" r="18" fill="white" filter="url(%23b)"/>
82
- </svg>`
83
- );
84
-
85
- return header + `
86
- ::view-transition-new(root) {
87
- mask: url('data:image/svg+xml,${svg}') top right / 0 no-repeat;
88
- -webkit-mask: url('data:image/svg+xml,${svg}') top right / 0 no-repeat;
89
- transform-origin: top right;
90
- animation: vt-scale-tr 0.9s;
91
- }
72
+ case "circle-left":
73
+ return header + circleReveal("0 0");
92
74
 
93
- ::view-transition-old(root), .dark::view-transition-old(root) {
94
- transform-origin: top right;
95
- animation: vt-scale-tr 0.9s;
96
- }
97
-
98
- @keyframes vt-scale-tr {
99
- to {
100
- mask-size: 350vmax;
101
- -webkit-mask-size: 350vmax;
102
- }
103
- }
104
- `;
105
- }
75
+ case "circle-right":
76
+ return header + circleReveal("100% 0");
106
77
  case "split-reveal-horizontal": {
107
78
  return header + `
108
79
  ::view-transition-new(root) {
@@ -241,7 +212,7 @@ export function buildAnimationCSS(animation, opts = {}) {
241
212
  }
242
213
  `;
243
214
  }
244
- // Add more animation
215
+ // Add more animation
245
216
 
246
217
 
247
218
 
@@ -249,4 +220,4 @@ export function buildAnimationCSS(animation, opts = {}) {
249
220
  default:
250
221
  return header; // no-op if unknown
251
222
  }
252
- }
223
+ }
@@ -71,7 +71,7 @@
71
71
  --color-aside-beam: #fff;
72
72
 
73
73
  /* animation defaults (can be overridden) */
74
- --vt-duration: 1s;
74
+ --vt-duration: 1.7s;
75
75
  --vt-ease: ease-in-out;
76
76
  }
77
77
 
@@ -1,7 +1,15 @@
1
1
  import { useState, useEffect, useRef, useId } from "react";
2
+ import { flushSync } from "react-dom";
2
3
  import { FiSun, FiMoon } from "react-icons/fi";
3
4
  import { buildAnimationCSS } from "./ThemeAnimation.js";
4
5
 
6
+ // Swaps the theme-* class on <html>.
7
+ function applyThemeClass(theme) {
8
+ const html = document.documentElement;
9
+ [...html.classList].forEach((c) => c.startsWith("theme-") && html.classList.remove(c));
10
+ html.classList.add(`theme-${theme}`);
11
+ }
12
+
5
13
  export default function ThemeToggle({
6
14
  animation = "circle",
7
15
  duration = "1.5s",
@@ -29,12 +37,19 @@ export default function ThemeToggle({
29
37
  // ------------------------------
30
38
  useEffect(() => {
31
39
  const rawCSS = buildAnimationCSS(animation, { duration, ease });
40
+ const suffix = uid.replace(/[^\w-]/g, "");
32
41
 
33
- // ✅ Scope selectors with html[vt-owner]
42
+ // ✅ Scope selectors with html[vt-owner]; :root variables and keyframe names
43
+ // too, so several toggles on one page don't override each other's settings.
34
44
  const scopedCSS = rawCSS
45
+ .replace(/(?<![-\w])vt-(?=[a-z])/g, `vt-${suffix}-`)
46
+ .replace(/:root\b/g, `html[vt-owner="${uid}"]`)
35
47
  .replaceAll("::view-transition-new", `html[vt-owner="${uid}"]::view-transition-new`)
36
48
  .replaceAll("::view-transition-old", `html[vt-owner="${uid}"]::view-transition-old`)
37
- .replaceAll("::view-transition-group", `html[vt-owner="${uid}"]::view-transition-group`);
49
+ .replaceAll("::view-transition-group", `html[vt-owner="${uid}"]::view-transition-group`)
50
+ // No CSS transitions while the view transition runs, otherwise elements are
51
+ // captured mid-fade in the old colours and snap to the new ones afterwards.
52
+ + `html[vt-owner="${uid}"] *, html[vt-owner="${uid}"] *::before, html[vt-owner="${uid}"] *::after { transition: none !important; }`;
38
53
 
39
54
  let styleEl = document.getElementById(`vt-style-${uid}`);
40
55
  if (!styleEl) {
@@ -49,9 +64,7 @@ export default function ThemeToggle({
49
64
  // Apply theme globally + notify
50
65
  // ------------------------------
51
66
  useEffect(() => {
52
- const html = document.documentElement;
53
- html.classList.forEach((c) => c.startsWith("theme-") && html.classList.remove(c));
54
- html.classList.add(`theme-${theme}`);
67
+ applyThemeClass(theme);
55
68
 
56
69
  try {
57
70
  localStorage.setItem("apexui-theme", theme);
@@ -88,7 +101,12 @@ export default function ThemeToggle({
88
101
  // ✅ Use html instead of body
89
102
  document.documentElement.setAttribute("vt-owner", uid);
90
103
 
91
- const run = () => setTheme(nextTheme);
104
+ // Apply the theme synchronously: the browser takes the transition's "new"
105
+ // snapshot as soon as this returns, before a normal React update would land.
106
+ const run = () => {
107
+ applyThemeClass(nextTheme);
108
+ flushSync(() => setTheme(nextTheme));
109
+ };
92
110
 
93
111
  if (document.startViewTransition) {
94
112
  const vt = document.startViewTransition(run);
@@ -115,4 +133,4 @@ export default function ThemeToggle({
115
133
  {isLight ? <FiMoon /> : <FiSun />}
116
134
  </button>
117
135
  );
118
- }
136
+ }