@inklu/docs 0.2.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.
- package/CHANGELOG.md +7 -0
- package/components.json +25 -0
- package/dist/index.d.mts +271 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +2455 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2368 -0
- package/dist/index.mjs.map +1 -0
- package/dist/shiki.d.mts +10 -0
- package/dist/shiki.d.ts +10 -0
- package/dist/shiki.js +47 -0
- package/dist/shiki.js.map +1 -0
- package/dist/shiki.mjs +22 -0
- package/dist/shiki.mjs.map +1 -0
- package/dist/styles.css +2556 -0
- package/package.json +55 -0
- package/src/components/content/changelog-content.tsx +119 -0
- package/src/components/content/code-block.tsx +111 -0
- package/src/components/content/docs-content.tsx +113 -0
- package/src/components/layout/docs-layout.tsx +192 -0
- package/src/components/layout/site-header.tsx +152 -0
- package/src/components/layout/site-layout.tsx +28 -0
- package/src/components/layout/site-provider.tsx +23 -0
- package/src/components/layout/site-template.tsx +20 -0
- package/src/components/layout/theme-provider.tsx +10 -0
- package/src/components/layout/theme-switcher.tsx +42 -0
- package/src/components/motion-primitives/morph-icon.tsx +97 -0
- package/src/components/motion-primitives/progressive-blur.tsx +64 -0
- package/src/components/motion-primitives/sliding-number.tsx +131 -0
- package/src/components/motion-primitives/text-effect.tsx +290 -0
- package/src/components/motion-primitives/text-morph.tsx +77 -0
- package/src/components/motion-primitives/text-scramble.tsx +87 -0
- package/src/components/shared/command-block.tsx +193 -0
- package/src/components/shared/scroll-fade.tsx +22 -0
- package/src/components/ui/badge.tsx +50 -0
- package/src/components/ui/brand-logo.tsx +68 -0
- package/src/components/ui/button-group.tsx +87 -0
- package/src/components/ui/button.tsx +104 -0
- package/src/components/ui/card.tsx +97 -0
- package/src/components/ui/direction.tsx +6 -0
- package/src/components/ui/hover-card.tsx +51 -0
- package/src/components/ui/item.tsx +201 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/toast.tsx +322 -0
- package/src/components/ui/tooltip.tsx +66 -0
- package/src/hooks/use-copy-to-clipboard.ts +54 -0
- package/src/index.ts +30 -0
- package/src/lib/constants.ts +63 -0
- package/src/lib/motions.ts +21 -0
- package/src/lib/utils/audio.ts +56 -0
- package/src/lib/utils.ts +14 -0
- package/src/shiki.ts +25 -0
- package/src/styles.css +112 -0
- package/src/typeset.css +513 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
|
4
|
+
import { motion } from "motion/react";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { usePathname } from "next/navigation";
|
|
7
|
+
import { ThemeSwitcher } from "@/components/layout/theme-switcher";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
import { tap as tapSound, turn as turnSound } from "@/lib/utils/audio";
|
|
11
|
+
import { Badge } from "../ui/badge";
|
|
12
|
+
import { BrandLogo } from "../ui/brand-logo";
|
|
13
|
+
import {
|
|
14
|
+
HoverCard,
|
|
15
|
+
HoverCardContent,
|
|
16
|
+
HoverCardTrigger,
|
|
17
|
+
} from "../ui/hover-card";
|
|
18
|
+
import {
|
|
19
|
+
Tooltip,
|
|
20
|
+
TooltipContent,
|
|
21
|
+
TooltipProvider,
|
|
22
|
+
TooltipTrigger,
|
|
23
|
+
} from "../ui/tooltip";
|
|
24
|
+
|
|
25
|
+
export type SiteHeaderProps = {
|
|
26
|
+
navItems?: { label: string; href: string }[];
|
|
27
|
+
logo?: React.ReactNode;
|
|
28
|
+
version?: string;
|
|
29
|
+
githubUrl?: string;
|
|
30
|
+
right?: React.ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function SiteHeader({
|
|
34
|
+
navItems = [],
|
|
35
|
+
logo = <BrandLogo />,
|
|
36
|
+
version = "v0.1",
|
|
37
|
+
githubUrl = "https://github.com/your-username/your-repo",
|
|
38
|
+
right,
|
|
39
|
+
}: SiteHeaderProps) {
|
|
40
|
+
const pathname = usePathname();
|
|
41
|
+
const isActive = (href: string) =>
|
|
42
|
+
href === "/" ? pathname === "/" : (pathname?.startsWith(href) ?? false);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<header className="sticky top-4 z-(--z-header) flex w-full items-center justify-between rounded-xl">
|
|
46
|
+
{/* Brand mark — doubles as the home link. */}
|
|
47
|
+
<Link
|
|
48
|
+
href="/"
|
|
49
|
+
aria-label="Home"
|
|
50
|
+
className="flex items-center text-foreground transition-opacity hover:opacity-80"
|
|
51
|
+
>
|
|
52
|
+
{logo}
|
|
53
|
+
</Link>
|
|
54
|
+
|
|
55
|
+
{/* Nav links, centered. */}
|
|
56
|
+
<nav className="absolute left-1/2 flex -translate-x-1/2 items-center gap-4 md:gap-5">
|
|
57
|
+
{navItems.map((item) => {
|
|
58
|
+
const active = isActive(item.href);
|
|
59
|
+
return (
|
|
60
|
+
<Link
|
|
61
|
+
key={item.href}
|
|
62
|
+
href={item.href}
|
|
63
|
+
aria-current={active ? "page" : undefined}
|
|
64
|
+
onClick={() => {
|
|
65
|
+
if (!active) turnSound(item.href === "/" ? "back" : "forward");
|
|
66
|
+
}}
|
|
67
|
+
className={cn(
|
|
68
|
+
"relative font-medium text-sm leading-5 transition-colors px-3 py-1.5 rounded-full",
|
|
69
|
+
active
|
|
70
|
+
? "text-foreground"
|
|
71
|
+
: "text-muted-foreground hover:text-foreground",
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{active && (
|
|
75
|
+
<motion.div
|
|
76
|
+
layoutId="activeHeaderNavBg"
|
|
77
|
+
className="absolute inset-0 bg-secondary/60 rounded-full"
|
|
78
|
+
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
<span className="relative z-10">{item.label}</span>
|
|
82
|
+
</Link>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</nav>
|
|
86
|
+
|
|
87
|
+
<div className="flex items-center gap-1">
|
|
88
|
+
{version && (
|
|
89
|
+
<HoverCard>
|
|
90
|
+
<HoverCardTrigger
|
|
91
|
+
render={
|
|
92
|
+
<Badge
|
|
93
|
+
variant={"secondary"}
|
|
94
|
+
className="align-middle cursor-default hover:bg-secondary/80 transition-colors"
|
|
95
|
+
>
|
|
96
|
+
{version}
|
|
97
|
+
</Badge>
|
|
98
|
+
}
|
|
99
|
+
/>
|
|
100
|
+
<HoverCardContent
|
|
101
|
+
side="bottom"
|
|
102
|
+
sideOffset={8}
|
|
103
|
+
align="center"
|
|
104
|
+
className="w-64 p-2"
|
|
105
|
+
>
|
|
106
|
+
<div className="space-y-1.5">
|
|
107
|
+
<h1 className=" font-medium">Release {version}</h1>
|
|
108
|
+
<p className="text-muted-foreground">
|
|
109
|
+
You are viewing the documentation for the latest release.
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
</HoverCardContent>
|
|
113
|
+
</HoverCard>
|
|
114
|
+
)}
|
|
115
|
+
{right}
|
|
116
|
+
{githubUrl && (
|
|
117
|
+
<TooltipProvider delay={200}>
|
|
118
|
+
<Tooltip>
|
|
119
|
+
<TooltipTrigger
|
|
120
|
+
render={
|
|
121
|
+
<Button
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="sm"
|
|
124
|
+
render={(props: React.ComponentProps<"a">) => (
|
|
125
|
+
<a
|
|
126
|
+
{...props}
|
|
127
|
+
href={githubUrl}
|
|
128
|
+
target="_blank"
|
|
129
|
+
rel="noopener noreferrer"
|
|
130
|
+
onClick={(e) => {
|
|
131
|
+
props.onClick?.(e);
|
|
132
|
+
tapSound();
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<GitHubLogoIcon />
|
|
138
|
+
<span className="hidden sm:inline">GitHub</span>
|
|
139
|
+
</Button>
|
|
140
|
+
}
|
|
141
|
+
/>
|
|
142
|
+
<TooltipContent side="bottom" sideOffset={6}>
|
|
143
|
+
View source on GitHub
|
|
144
|
+
</TooltipContent>
|
|
145
|
+
</Tooltip>
|
|
146
|
+
</TooltipProvider>
|
|
147
|
+
)}
|
|
148
|
+
<ThemeSwitcher />
|
|
149
|
+
</div>
|
|
150
|
+
</header>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { ScrollFade } from "../shared/scroll-fade";
|
|
3
|
+
|
|
4
|
+
export type SiteLayoutProps = {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
header?: React.ReactNode;
|
|
7
|
+
footer?: React.ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function SiteLayout({ children, header, footer }: SiteLayoutProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="relative flex flex-col items-center min-h-screen pb-24 overflow-x-clip">
|
|
13
|
+
{/* Top scroll fade */}
|
|
14
|
+
<ScrollFade />
|
|
15
|
+
|
|
16
|
+
<section className="w-full px-6 flex flex-col gap-3 pt-3">
|
|
17
|
+
{header}
|
|
18
|
+
{children}
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
{footer && (
|
|
22
|
+
<div className="flex flex-col gap-10 items-center text-center mt-32 px-4">
|
|
23
|
+
{footer}
|
|
24
|
+
</div>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
import { DirectionProvider } from "../ui/direction";
|
|
5
|
+
import { AnchoredToastProvider, ToastProvider } from "../ui/toast";
|
|
6
|
+
import { ThemeProvider } from "./theme-provider";
|
|
7
|
+
|
|
8
|
+
export function SiteProvider({ children }: { children: React.ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<ThemeProvider
|
|
11
|
+
attribute="class"
|
|
12
|
+
defaultTheme="dark"
|
|
13
|
+
enableSystem
|
|
14
|
+
disableTransitionOnChange
|
|
15
|
+
>
|
|
16
|
+
<DirectionProvider direction="ltr">
|
|
17
|
+
<ToastProvider position="bottom-right">
|
|
18
|
+
<AnchoredToastProvider>{children}</AnchoredToastProvider>
|
|
19
|
+
</ToastProvider>
|
|
20
|
+
</DirectionProvider>
|
|
21
|
+
</ThemeProvider>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion, useReducedMotion } from "motion/react";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
|
|
6
|
+
export function SiteTemplate({ children }: { children: React.ReactNode }) {
|
|
7
|
+
const reduced = useReducedMotion();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<motion.div
|
|
11
|
+
initial={reduced ? false : { opacity: 0 }}
|
|
12
|
+
animate={{ opacity: 1 }}
|
|
13
|
+
transition={
|
|
14
|
+
reduced ? { duration: 0 } : { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
|
|
15
|
+
}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</motion.div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
|
4
|
+
import type { ComponentProps } from "react";
|
|
5
|
+
|
|
6
|
+
export function ThemeProvider(
|
|
7
|
+
props: ComponentProps<typeof NextThemesProvider>,
|
|
8
|
+
) {
|
|
9
|
+
return <NextThemesProvider {...props} />;
|
|
10
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { MorphIcon } from "../motion-primitives/morph-icon";
|
|
8
|
+
|
|
9
|
+
export function ThemeSwitcher() {
|
|
10
|
+
const { theme, setTheme, systemTheme } = useTheme();
|
|
11
|
+
const [mounted, setMounted] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setMounted(true);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
if (!mounted) {
|
|
18
|
+
return (
|
|
19
|
+
<Button variant="ghost" size="icon-sm" aria-label="Toggle theme">
|
|
20
|
+
<SunIcon aria-hidden="true" />
|
|
21
|
+
</Button>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const currentTheme = theme === "system" ? systemTheme : theme;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Button
|
|
29
|
+
variant="ghost"
|
|
30
|
+
size="icon-sm"
|
|
31
|
+
onClick={() => setTheme(currentTheme === "dark" ? "light" : "dark")}
|
|
32
|
+
aria-label="Toggle theme"
|
|
33
|
+
>
|
|
34
|
+
<MorphIcon
|
|
35
|
+
isActive={currentTheme === "dark"}
|
|
36
|
+
activeIcon={<MoonIcon aria-hidden="true" />}
|
|
37
|
+
inactiveIcon={<SunIcon aria-hidden="true" />}
|
|
38
|
+
variant="rotate"
|
|
39
|
+
/>
|
|
40
|
+
</Button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
|
|
6
|
+
const CARD_DELAY_STEP = 0.067;
|
|
7
|
+
|
|
8
|
+
// Active permutation: TR BR / TL BL (indices in a 2×2 grid 0-3)
|
|
9
|
+
const ACTIVE_ORDER = [1, 3, 0, 2];
|
|
10
|
+
const DEFAULT_ORDER = [0, 1, 2, 3];
|
|
11
|
+
|
|
12
|
+
export interface CardsViewIconProps {
|
|
13
|
+
/** Whether the icon is in its active (animated) state. */
|
|
14
|
+
isActive: boolean;
|
|
15
|
+
/** Extra classes applied to the root element. */
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CardsViewIcon({ isActive, className }: CardsViewIconProps) {
|
|
20
|
+
const order = isActive ? ACTIVE_ORDER : DEFAULT_ORDER;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<motion.span
|
|
24
|
+
className={`inline-grid h-3 w-3 place-content-center grid-cols-2 gap-[2.5px] ${className ?? ""}`}
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
>
|
|
27
|
+
{order.map((id, i) => (
|
|
28
|
+
<motion.span
|
|
29
|
+
key={id}
|
|
30
|
+
layout
|
|
31
|
+
className="size-[4px] rounded-[1px] bg-current"
|
|
32
|
+
animate={isActive ? { opacity: [0.65, 1, 1] } : { opacity: 1 }}
|
|
33
|
+
transition={{
|
|
34
|
+
duration: 0.42,
|
|
35
|
+
delay: i === 0 ? 0 : i * CARD_DELAY_STEP,
|
|
36
|
+
ease: "easeOut",
|
|
37
|
+
layout: {
|
|
38
|
+
duration: 0.45,
|
|
39
|
+
delay: i === 0 ? 0 : i * CARD_DELAY_STEP,
|
|
40
|
+
ease: "easeInOut",
|
|
41
|
+
},
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
))}
|
|
45
|
+
</motion.span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MorphIconProps {
|
|
50
|
+
isActive: boolean;
|
|
51
|
+
activeIcon: React.ReactNode;
|
|
52
|
+
inactiveIcon: React.ReactNode;
|
|
53
|
+
className?: string;
|
|
54
|
+
variant?: "scale" | "rotate" | "slide";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function MorphIcon({
|
|
58
|
+
isActive,
|
|
59
|
+
activeIcon,
|
|
60
|
+
inactiveIcon,
|
|
61
|
+
className,
|
|
62
|
+
variant = "rotate",
|
|
63
|
+
}: MorphIconProps) {
|
|
64
|
+
const variants = {
|
|
65
|
+
scale: {
|
|
66
|
+
initial: { opacity: 0, scale: 0.95 },
|
|
67
|
+
animate: { opacity: 1, scale: 1 },
|
|
68
|
+
exit: { opacity: 0, scale: 0.95 },
|
|
69
|
+
},
|
|
70
|
+
rotate: {
|
|
71
|
+
initial: { opacity: 0, rotate: -90, scale: 0.95 },
|
|
72
|
+
animate: { opacity: 1, rotate: 0, scale: 1 },
|
|
73
|
+
exit: { opacity: 0, rotate: 90, scale: 0.95 },
|
|
74
|
+
},
|
|
75
|
+
slide: {
|
|
76
|
+
initial: { opacity: 0, y: -10 },
|
|
77
|
+
animate: { opacity: 1, y: 0 },
|
|
78
|
+
exit: { opacity: 0, y: 10 },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
84
|
+
<motion.span
|
|
85
|
+
key={isActive ? "active" : "inactive"}
|
|
86
|
+
initial="initial"
|
|
87
|
+
animate="animate"
|
|
88
|
+
exit="exit"
|
|
89
|
+
variants={variants[variant]}
|
|
90
|
+
transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}
|
|
91
|
+
className={`inline-flex items-center justify-center ${className ?? ""}`}
|
|
92
|
+
>
|
|
93
|
+
{isActive ? activeIcon : inactiveIcon}
|
|
94
|
+
</motion.span>
|
|
95
|
+
</AnimatePresence>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { type HTMLMotionProps, motion } from "motion/react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export const GRADIENT_ANGLES = {
|
|
6
|
+
top: 0,
|
|
7
|
+
right: 90,
|
|
8
|
+
bottom: 180,
|
|
9
|
+
left: 270,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ProgressiveBlurProps = {
|
|
13
|
+
direction?: keyof typeof GRADIENT_ANGLES;
|
|
14
|
+
blurLayers?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
blurIntensity?: number;
|
|
17
|
+
} & HTMLMotionProps<"div">;
|
|
18
|
+
|
|
19
|
+
export function ProgressiveBlur({
|
|
20
|
+
direction = "bottom",
|
|
21
|
+
blurLayers = 8,
|
|
22
|
+
className,
|
|
23
|
+
blurIntensity = 0.25,
|
|
24
|
+
...props
|
|
25
|
+
}: ProgressiveBlurProps) {
|
|
26
|
+
const layers = Math.max(blurLayers, 2);
|
|
27
|
+
const segmentSize = 1 / (blurLayers + 1);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn("relative", className)}>
|
|
31
|
+
{Array.from({ length: layers }).map((_, index) => {
|
|
32
|
+
const angle = GRADIENT_ANGLES[direction];
|
|
33
|
+
const gradientStops = [
|
|
34
|
+
index * segmentSize,
|
|
35
|
+
(index + 1) * segmentSize,
|
|
36
|
+
(index + 2) * segmentSize,
|
|
37
|
+
(index + 3) * segmentSize,
|
|
38
|
+
].map(
|
|
39
|
+
(pos, posIndex) =>
|
|
40
|
+
`rgba(255, 255, 255, ${posIndex === 1 || posIndex === 2 ? 1 : 0}) ${pos * 100}%`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const gradient = `linear-gradient(${angle}deg, ${gradientStops.join(
|
|
44
|
+
", ",
|
|
45
|
+
)})`;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<motion.div
|
|
49
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: Layers are static
|
|
50
|
+
key={index}
|
|
51
|
+
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
|
52
|
+
style={{
|
|
53
|
+
maskImage: gradient,
|
|
54
|
+
WebkitMaskImage: gradient,
|
|
55
|
+
backdropFilter: `blur(${index * blurIntensity}px)`,
|
|
56
|
+
WebkitBackdropFilter: `blur(${index * blurIntensity}px)`,
|
|
57
|
+
}}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
type MotionValue,
|
|
4
|
+
motion,
|
|
5
|
+
motionValue,
|
|
6
|
+
useSpring,
|
|
7
|
+
useTransform,
|
|
8
|
+
} from "motion/react";
|
|
9
|
+
import { useEffect, useId } from "react";
|
|
10
|
+
import useMeasure from "react-use-measure";
|
|
11
|
+
|
|
12
|
+
const TRANSITION = {
|
|
13
|
+
type: "spring" as const,
|
|
14
|
+
bounce: 0,
|
|
15
|
+
duration: 0.3,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function Digit({ value, place }: { value: number; place: number }) {
|
|
19
|
+
const valueRoundedToPlace = Math.floor(value / place) % 10;
|
|
20
|
+
const initial = motionValue(valueRoundedToPlace);
|
|
21
|
+
const animatedValue = useSpring(initial, TRANSITION);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
animatedValue.set(valueRoundedToPlace);
|
|
25
|
+
}, [animatedValue, valueRoundedToPlace]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums">
|
|
29
|
+
<div className="invisible">0</div>
|
|
30
|
+
{Array.from({ length: 10 }, (_, i) => (
|
|
31
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: order is static
|
|
32
|
+
<SlidingDigitNumber key={`digit-${i}`} mv={animatedValue} number={i} />
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function SlidingDigitNumber({
|
|
39
|
+
mv,
|
|
40
|
+
number,
|
|
41
|
+
}: {
|
|
42
|
+
mv: MotionValue<number>;
|
|
43
|
+
number: number;
|
|
44
|
+
}) {
|
|
45
|
+
const uniqueId = useId();
|
|
46
|
+
const [ref, bounds] = useMeasure();
|
|
47
|
+
|
|
48
|
+
const y = useTransform(mv, (latest) => {
|
|
49
|
+
if (!bounds.height) return 0;
|
|
50
|
+
const placeValue = latest % 10;
|
|
51
|
+
const offset = (10 + number - placeValue) % 10;
|
|
52
|
+
let memo = offset * bounds.height;
|
|
53
|
+
|
|
54
|
+
if (offset > 5) {
|
|
55
|
+
memo -= 10 * bounds.height;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return memo;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// don't render the animated number until we know the height
|
|
62
|
+
if (!bounds.height) {
|
|
63
|
+
return (
|
|
64
|
+
<span ref={ref} className="invisible absolute">
|
|
65
|
+
{number}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<motion.span
|
|
72
|
+
style={{ y }}
|
|
73
|
+
layoutId={`${uniqueId}-${number}`}
|
|
74
|
+
className="absolute inset-0 flex items-center justify-center"
|
|
75
|
+
transition={TRANSITION}
|
|
76
|
+
ref={ref}
|
|
77
|
+
>
|
|
78
|
+
{number}
|
|
79
|
+
</motion.span>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type SlidingNumberProps = {
|
|
84
|
+
value: number;
|
|
85
|
+
padStart?: boolean;
|
|
86
|
+
decimalSeparator?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function SlidingNumber({
|
|
90
|
+
value,
|
|
91
|
+
padStart = false,
|
|
92
|
+
decimalSeparator = ".",
|
|
93
|
+
}: SlidingNumberProps) {
|
|
94
|
+
const absValue = Math.abs(value);
|
|
95
|
+
const [integerPart, decimalPart] = absValue.toString().split(".");
|
|
96
|
+
const integerValue = parseInt(integerPart, 10);
|
|
97
|
+
const paddedInteger =
|
|
98
|
+
padStart && integerValue < 10 ? `0${integerPart}` : integerPart;
|
|
99
|
+
const integerDigits = paddedInteger.split("");
|
|
100
|
+
const integerPlaces = integerDigits.map(
|
|
101
|
+
(_, i) => 10 ** (integerDigits.length - i - 1),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex items-center">
|
|
106
|
+
{value < 0 && "-"}
|
|
107
|
+
{integerDigits.map((_, index) => (
|
|
108
|
+
<Digit
|
|
109
|
+
key={`pos-${integerPlaces[index]}`}
|
|
110
|
+
value={integerValue}
|
|
111
|
+
place={integerPlaces[index]}
|
|
112
|
+
/>
|
|
113
|
+
))}
|
|
114
|
+
{decimalPart && (
|
|
115
|
+
<>
|
|
116
|
+
<span>{decimalSeparator}</span>
|
|
117
|
+
{decimalPart.split("").map((_, index) => {
|
|
118
|
+
const place = 10 ** (decimalPart.length - index - 1);
|
|
119
|
+
return (
|
|
120
|
+
<Digit
|
|
121
|
+
key={`decimal-${place}`}
|
|
122
|
+
value={parseInt(decimalPart, 10)}
|
|
123
|
+
place={place}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|