@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/components.json +25 -0
  3. package/dist/index.d.mts +271 -0
  4. package/dist/index.d.ts +271 -0
  5. package/dist/index.js +2455 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +2368 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/shiki.d.mts +10 -0
  10. package/dist/shiki.d.ts +10 -0
  11. package/dist/shiki.js +47 -0
  12. package/dist/shiki.js.map +1 -0
  13. package/dist/shiki.mjs +22 -0
  14. package/dist/shiki.mjs.map +1 -0
  15. package/dist/styles.css +2556 -0
  16. package/package.json +55 -0
  17. package/src/components/content/changelog-content.tsx +119 -0
  18. package/src/components/content/code-block.tsx +111 -0
  19. package/src/components/content/docs-content.tsx +113 -0
  20. package/src/components/layout/docs-layout.tsx +192 -0
  21. package/src/components/layout/site-header.tsx +152 -0
  22. package/src/components/layout/site-layout.tsx +28 -0
  23. package/src/components/layout/site-provider.tsx +23 -0
  24. package/src/components/layout/site-template.tsx +20 -0
  25. package/src/components/layout/theme-provider.tsx +10 -0
  26. package/src/components/layout/theme-switcher.tsx +42 -0
  27. package/src/components/motion-primitives/morph-icon.tsx +97 -0
  28. package/src/components/motion-primitives/progressive-blur.tsx +64 -0
  29. package/src/components/motion-primitives/sliding-number.tsx +131 -0
  30. package/src/components/motion-primitives/text-effect.tsx +290 -0
  31. package/src/components/motion-primitives/text-morph.tsx +77 -0
  32. package/src/components/motion-primitives/text-scramble.tsx +87 -0
  33. package/src/components/shared/command-block.tsx +193 -0
  34. package/src/components/shared/scroll-fade.tsx +22 -0
  35. package/src/components/ui/badge.tsx +50 -0
  36. package/src/components/ui/brand-logo.tsx +68 -0
  37. package/src/components/ui/button-group.tsx +87 -0
  38. package/src/components/ui/button.tsx +104 -0
  39. package/src/components/ui/card.tsx +97 -0
  40. package/src/components/ui/direction.tsx +6 -0
  41. package/src/components/ui/hover-card.tsx +51 -0
  42. package/src/components/ui/item.tsx +201 -0
  43. package/src/components/ui/separator.tsx +25 -0
  44. package/src/components/ui/toast.tsx +322 -0
  45. package/src/components/ui/tooltip.tsx +66 -0
  46. package/src/hooks/use-copy-to-clipboard.ts +54 -0
  47. package/src/index.ts +30 -0
  48. package/src/lib/constants.ts +63 -0
  49. package/src/lib/motions.ts +21 -0
  50. package/src/lib/utils/audio.ts +56 -0
  51. package/src/lib/utils.ts +14 -0
  52. package/src/shiki.ts +25 -0
  53. package/src/styles.css +112 -0
  54. package/src/typeset.css +513 -0
  55. package/tsconfig.json +15 -0
  56. package/tsup.config.ts +11 -0
@@ -0,0 +1,290 @@
1
+ "use client";
2
+ import type {
3
+ TargetAndTransition,
4
+ Transition,
5
+ Variant,
6
+ Variants,
7
+ } from "motion/react";
8
+ import { AnimatePresence, motion } from "motion/react";
9
+ import React from "react";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ export type PresetType = "blur" | "fade-in-blur" | "scale" | "fade" | "slide";
13
+
14
+ export type PerType = "word" | "char" | "line";
15
+
16
+ export type TextEffectProps = {
17
+ children: string;
18
+ per?: PerType;
19
+ as?: keyof React.JSX.IntrinsicElements;
20
+ variants?: {
21
+ container?: Variants;
22
+ item?: Variants;
23
+ };
24
+ className?: string;
25
+ preset?: PresetType;
26
+ delay?: number;
27
+ speedReveal?: number;
28
+ speedSegment?: number;
29
+ trigger?: boolean;
30
+ onAnimationComplete?: () => void;
31
+ onAnimationStart?: () => void;
32
+ segmentWrapperClassName?: string;
33
+ containerTransition?: Transition;
34
+ segmentTransition?: Transition;
35
+ style?: React.CSSProperties;
36
+ };
37
+
38
+ const defaultStaggerTimes: Record<PerType, number> = {
39
+ char: 0.03,
40
+ word: 0.05,
41
+ line: 0.1,
42
+ };
43
+
44
+ const defaultContainerVariants: Variants = {
45
+ hidden: { opacity: 0 },
46
+ visible: {
47
+ opacity: 1,
48
+ transition: {
49
+ staggerChildren: 0.05,
50
+ },
51
+ },
52
+ exit: {
53
+ transition: { staggerChildren: 0.05, staggerDirection: -1 },
54
+ },
55
+ };
56
+
57
+ const defaultItemVariants: Variants = {
58
+ hidden: { opacity: 0 },
59
+ visible: {
60
+ opacity: 1,
61
+ },
62
+ exit: { opacity: 0 },
63
+ };
64
+
65
+ const presetVariants: Record<
66
+ PresetType,
67
+ { container: Variants; item: Variants }
68
+ > = {
69
+ blur: {
70
+ container: defaultContainerVariants,
71
+ item: {
72
+ hidden: { opacity: 0, filter: "blur(12px)" },
73
+ visible: { opacity: 1, filter: "blur(0px)" },
74
+ exit: { opacity: 0, filter: "blur(12px)" },
75
+ },
76
+ },
77
+ "fade-in-blur": {
78
+ container: defaultContainerVariants,
79
+ item: {
80
+ hidden: { opacity: 0, y: 8, filter: "blur(12px)" },
81
+ visible: { opacity: 1, y: 0, filter: "blur(0px)" },
82
+ exit: { opacity: 0, y: 8, filter: "blur(12px)" },
83
+ },
84
+ },
85
+ scale: {
86
+ container: defaultContainerVariants,
87
+ item: {
88
+ hidden: { opacity: 0, scale: 0.95 },
89
+ visible: { opacity: 1, scale: 1 },
90
+ exit: { opacity: 0, scale: 0.95 },
91
+ },
92
+ },
93
+ fade: {
94
+ container: defaultContainerVariants,
95
+ item: {
96
+ hidden: { opacity: 0 },
97
+ visible: { opacity: 1 },
98
+ exit: { opacity: 0 },
99
+ },
100
+ },
101
+ slide: {
102
+ container: defaultContainerVariants,
103
+ item: {
104
+ hidden: { opacity: 0, y: 8 },
105
+ visible: { opacity: 1, y: 0 },
106
+ exit: { opacity: 0, y: 8 },
107
+ },
108
+ },
109
+ };
110
+
111
+ const AnimationComponent: React.FC<{
112
+ segment: string;
113
+ variants: Variants;
114
+ per: "line" | "word" | "char";
115
+ segmentWrapperClassName?: string;
116
+ }> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
117
+ const content =
118
+ per === "line" ? (
119
+ <motion.span variants={variants} className="block">
120
+ {segment}
121
+ </motion.span>
122
+ ) : per === "word" ? (
123
+ <motion.span
124
+ aria-hidden="true"
125
+ variants={variants}
126
+ className="inline-block whitespace-pre"
127
+ >
128
+ {segment}
129
+ </motion.span>
130
+ ) : (
131
+ <motion.span className="inline-block whitespace-pre">
132
+ {segment.split("").map((char, charIndex) => (
133
+ <motion.span
134
+ // biome-ignore lint/suspicious/noArrayIndexKey: Characters are static and don't change order
135
+ key={`char-${charIndex}`}
136
+ aria-hidden="true"
137
+ variants={variants}
138
+ className="inline-block whitespace-pre"
139
+ >
140
+ {char}
141
+ </motion.span>
142
+ ))}
143
+ </motion.span>
144
+ );
145
+
146
+ if (!segmentWrapperClassName) {
147
+ return content;
148
+ }
149
+
150
+ const defaultWrapperClassName = per === "line" ? "block" : "inline-block";
151
+
152
+ return (
153
+ <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
154
+ {content}
155
+ </span>
156
+ );
157
+ });
158
+
159
+ AnimationComponent.displayName = "AnimationComponent";
160
+
161
+ const splitText = (text: string, per: PerType) => {
162
+ if (per === "line") return text.split("\n");
163
+ return text.split(/(\s+)/);
164
+ };
165
+
166
+ const hasTransition = (
167
+ variant?: Variant,
168
+ ): variant is TargetAndTransition & { transition?: Transition } => {
169
+ if (!variant) return false;
170
+ return typeof variant === "object" && "transition" in variant;
171
+ };
172
+
173
+ const createVariantsWithTransition = (
174
+ baseVariants: Variants,
175
+ transition?: Transition & { exit?: Transition },
176
+ ): Variants => {
177
+ if (!transition) return baseVariants;
178
+
179
+ const { exit: _, ...mainTransition } = transition;
180
+
181
+ return {
182
+ ...baseVariants,
183
+ visible: {
184
+ ...baseVariants.visible,
185
+ transition: {
186
+ ...(hasTransition(baseVariants.visible)
187
+ ? baseVariants.visible.transition
188
+ : {}),
189
+ ...mainTransition,
190
+ },
191
+ },
192
+ exit: {
193
+ ...baseVariants.exit,
194
+ transition: {
195
+ ...(hasTransition(baseVariants.exit)
196
+ ? baseVariants.exit.transition
197
+ : {}),
198
+ ...mainTransition,
199
+ staggerDirection: -1,
200
+ },
201
+ },
202
+ };
203
+ };
204
+
205
+ export function TextEffect({
206
+ children,
207
+ per = "word",
208
+ as = "p",
209
+ variants,
210
+ className,
211
+ preset = "fade",
212
+ delay = 0,
213
+ speedReveal = 1,
214
+ speedSegment = 1,
215
+ trigger = true,
216
+ onAnimationComplete,
217
+ onAnimationStart,
218
+ segmentWrapperClassName,
219
+ containerTransition,
220
+ segmentTransition,
221
+ style,
222
+ }: TextEffectProps) {
223
+ const segments = splitText(children, per);
224
+ const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
225
+
226
+ const baseVariants = preset
227
+ ? presetVariants[preset]
228
+ : { container: defaultContainerVariants, item: defaultItemVariants };
229
+
230
+ const stagger = defaultStaggerTimes[per] / speedReveal;
231
+
232
+ const baseDuration = 0.3 / speedSegment;
233
+
234
+ const customStagger = hasTransition(variants?.container?.visible ?? {})
235
+ ? (variants?.container?.visible as TargetAndTransition).transition
236
+ ?.staggerChildren
237
+ : undefined;
238
+
239
+ const customDelay = hasTransition(variants?.container?.visible ?? {})
240
+ ? (variants?.container?.visible as TargetAndTransition).transition
241
+ ?.delayChildren
242
+ : undefined;
243
+
244
+ const computedVariants = {
245
+ container: createVariantsWithTransition(
246
+ variants?.container || baseVariants.container,
247
+ {
248
+ staggerChildren: customStagger ?? stagger,
249
+ delayChildren: customDelay ?? delay,
250
+ ...containerTransition,
251
+ exit: {
252
+ staggerChildren: customStagger ?? stagger,
253
+ staggerDirection: -1,
254
+ },
255
+ },
256
+ ),
257
+ item: createVariantsWithTransition(variants?.item || baseVariants.item, {
258
+ duration: baseDuration,
259
+ ...segmentTransition,
260
+ }),
261
+ };
262
+
263
+ return (
264
+ <AnimatePresence mode="popLayout">
265
+ {trigger && (
266
+ <MotionTag
267
+ initial="hidden"
268
+ animate="visible"
269
+ exit="exit"
270
+ variants={computedVariants.container}
271
+ className={className}
272
+ onAnimationComplete={onAnimationComplete}
273
+ onAnimationStart={onAnimationStart}
274
+ style={style}
275
+ >
276
+ {per !== "line" ? <span className="sr-only">{children}</span> : null}
277
+ {segments.map((segment, index) => (
278
+ <AnimationComponent
279
+ key={`${per}-${index}-${segment}`}
280
+ segment={segment}
281
+ variants={computedVariants.item}
282
+ per={per}
283
+ segmentWrapperClassName={segmentWrapperClassName}
284
+ />
285
+ ))}
286
+ </MotionTag>
287
+ )}
288
+ </AnimatePresence>
289
+ );
290
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import {
3
+ AnimatePresence,
4
+ motion,
5
+ type Transition,
6
+ type Variants,
7
+ } from "motion/react";
8
+ import { useId, useMemo } from "react";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ export type TextMorphProps = {
12
+ children: string;
13
+ as?: React.ElementType;
14
+ className?: string;
15
+ style?: React.CSSProperties;
16
+ variants?: Variants;
17
+ transition?: Transition;
18
+ };
19
+
20
+ export function TextMorph({
21
+ children,
22
+ as: Component = "p",
23
+ className,
24
+ style,
25
+ variants,
26
+ transition,
27
+ }: TextMorphProps) {
28
+ const uniqueId = useId();
29
+
30
+ const characters = useMemo(() => {
31
+ const charCounts: Record<string, number> = {};
32
+
33
+ return children.split("").map((char) => {
34
+ const lowerChar = char.toLowerCase();
35
+ charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
36
+
37
+ return {
38
+ id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
39
+ label: char === " " ? "\u00A0" : char,
40
+ };
41
+ });
42
+ }, [children, uniqueId]);
43
+
44
+ const defaultVariants: Variants = {
45
+ initial: { opacity: 0 },
46
+ animate: { opacity: 1 },
47
+ exit: { opacity: 0 },
48
+ };
49
+
50
+ const defaultTransition: Transition = {
51
+ type: "spring",
52
+ bounce: 0,
53
+ duration: 0.3,
54
+ };
55
+
56
+ return (
57
+ <Component className={cn(className)} aria-label={children} style={style}>
58
+ <AnimatePresence mode="popLayout" initial={false}>
59
+ {characters.map((character) => (
60
+ <motion.span
61
+ key={character.id}
62
+ layoutId={character.id}
63
+ className="inline-block"
64
+ aria-hidden="true"
65
+ initial="initial"
66
+ animate="animate"
67
+ exit="exit"
68
+ variants={variants || defaultVariants}
69
+ transition={transition || defaultTransition}
70
+ >
71
+ {character.label}
72
+ </motion.span>
73
+ ))}
74
+ </AnimatePresence>
75
+ </Component>
76
+ );
77
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ import { type MotionProps, motion } from "motion/react";
3
+ import { type JSX, useEffect, useState } from "react";
4
+
5
+ export type TextScrambleProps = {
6
+ children: string;
7
+ duration?: number;
8
+ speed?: number;
9
+ characterSet?: string;
10
+ as?: React.ElementType;
11
+ className?: string;
12
+ trigger?: boolean;
13
+ onScrambleComplete?: () => void;
14
+ } & MotionProps;
15
+
16
+ const defaultChars =
17
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
18
+
19
+ export function TextScramble({
20
+ children,
21
+ duration = 0.8,
22
+ speed = 0.04,
23
+ characterSet = defaultChars,
24
+ className,
25
+ as: Component = "p",
26
+ trigger = true,
27
+ onScrambleComplete,
28
+ ...props
29
+ }: TextScrambleProps) {
30
+ const MotionComponent = motion.create(
31
+ Component as keyof JSX.IntrinsicElements,
32
+ );
33
+ const [scrambledText, setScrambledText] = useState<string | null>(null);
34
+ const [isAnimating, setIsAnimating] = useState(false);
35
+ const text = children;
36
+ const displayText = scrambledText ?? children;
37
+
38
+ const scramble = async () => {
39
+ if (isAnimating) return;
40
+ setIsAnimating(true);
41
+
42
+ const steps = duration / speed;
43
+ let step = 0;
44
+
45
+ const interval = setInterval(() => {
46
+ let scrambled = "";
47
+ const progress = step / steps;
48
+
49
+ for (let i = 0; i < text.length; i++) {
50
+ if (text[i] === " ") {
51
+ scrambled += " ";
52
+ continue;
53
+ }
54
+
55
+ if (progress * text.length > i) {
56
+ scrambled += text[i];
57
+ } else {
58
+ scrambled +=
59
+ characterSet[Math.floor(Math.random() * characterSet.length)];
60
+ }
61
+ }
62
+
63
+ setScrambledText(scrambled);
64
+ step++;
65
+
66
+ if (step > steps) {
67
+ clearInterval(interval);
68
+ setScrambledText(null);
69
+ setIsAnimating(false);
70
+ onScrambleComplete?.();
71
+ }
72
+ }, speed * 1000);
73
+ };
74
+
75
+ useEffect(() => {
76
+ if (!trigger) return;
77
+
78
+ scramble();
79
+ // biome-ignore lint/correctness/useExhaustiveDependencies: trigger controls the animation
80
+ }, [trigger, scramble]);
81
+
82
+ return (
83
+ <MotionComponent className={className} {...props}>
84
+ {displayText}
85
+ </MotionComponent>
86
+ );
87
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
4
+ import { useRef, useState } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { anchoredToastManager } from "@/components/ui/toast";
7
+ import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
8
+ import { cn } from "@/lib/utils";
9
+ import { deny, tap } from "@/lib/utils/audio";
10
+ import { TextMorph } from "../motion-primitives/text-morph";
11
+
12
+ const SLIDE_SPRING = { type: "spring", stiffness: 520, damping: 34 } as const;
13
+
14
+ const MORPH_EASE = { duration: 0.16, ease: "easeOut" } as const;
15
+
16
+ export function CopyMorphIcon({ copied }: { copied: boolean }) {
17
+ const reduced = useReducedMotion() ?? false;
18
+ const t = reduced ? { duration: 0 } : MORPH_EASE;
19
+
20
+ return (
21
+ <span
22
+ aria-hidden="true"
23
+ className="relative grid size-3.5 place-items-center"
24
+ >
25
+ <AnimatePresence initial={false} mode="popLayout">
26
+ {copied ? (
27
+ <motion.svg
28
+ key="check"
29
+ aria-hidden="true"
30
+ width="15"
31
+ height="15"
32
+ viewBox="0 0 16 16"
33
+ fill="none"
34
+ className="absolute text-foreground"
35
+ initial={{ opacity: 0, scale: 0.8 }}
36
+ animate={{ opacity: 1, scale: 1 }}
37
+ exit={{ opacity: 0, scale: 0.8 }}
38
+ transition={t}
39
+ >
40
+ <motion.path
41
+ d="M1.83398 10.0625L6.00065 13.5L14.1673 2.5"
42
+ stroke="currentColor"
43
+ strokeWidth="1.25"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ initial={{ pathLength: reduced ? 1 : 0 }}
47
+ animate={{ pathLength: 1 }}
48
+ transition={
49
+ reduced ? { duration: 0 } : { duration: 0.18, ease: "easeOut" }
50
+ }
51
+ />
52
+ </motion.svg>
53
+ ) : (
54
+ <motion.svg
55
+ key="copy"
56
+ aria-hidden="true"
57
+ width="15"
58
+ height="15"
59
+ viewBox="0 0 16 16"
60
+ fill="none"
61
+ className="absolute"
62
+ initial={{ opacity: 0, scale: 0.8 }}
63
+ animate={{ opacity: 1, scale: 1 }}
64
+ exit={{ opacity: 0, scale: 0.8 }}
65
+ transition={t}
66
+ >
67
+ <path
68
+ d="M10.1667 3.16634H12.8334V14.1663H3.16675V3.16634H5.83341M5.83341 1.83301H10.1667V4.83301H5.83341V1.83301Z"
69
+ stroke="currentColor"
70
+ strokeWidth="1.25"
71
+ strokeLinecap="round"
72
+ strokeLinejoin="round"
73
+ />
74
+ </motion.svg>
75
+ )}
76
+ </AnimatePresence>
77
+ </span>
78
+ );
79
+ }
80
+
81
+ export function CommandBlock({
82
+ items,
83
+ }: {
84
+ items: { id: string; command: string }[];
85
+ }) {
86
+ const [active, setActive] = useState(0);
87
+ const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 2000 });
88
+ const reduced = useReducedMotion() ?? false;
89
+ const cur = items[active];
90
+ const copyButtonRef = useRef<HTMLButtonElement>(null);
91
+
92
+ const handleCopy = async () => {
93
+ const success = await copyToClipboard(cur.command);
94
+ if (success) {
95
+ tap();
96
+ anchoredToastManager.add({
97
+ title: "Copied!",
98
+ positionerProps: {
99
+ anchor: copyButtonRef.current,
100
+ sideOffset: 6,
101
+ },
102
+ data: {
103
+ tooltipStyle: true,
104
+ },
105
+ timeout: 2000,
106
+ });
107
+ } else {
108
+ deny();
109
+ anchoredToastManager.add({
110
+ title: "Failed to copy",
111
+ positionerProps: {
112
+ anchor: copyButtonRef.current,
113
+ sideOffset: 6,
114
+ },
115
+ data: {
116
+ tooltipStyle: true,
117
+ },
118
+ timeout: 2000,
119
+ });
120
+ }
121
+ };
122
+
123
+ return (
124
+ <div className="not-typeset mt-6 rounded-2xl border-0 bg-code overflow-hidden">
125
+ {items.length > 1 && (
126
+ <div className="flex items-center gap-0.5 px-3 pb-1.5 pt-3 justify-between">
127
+ <div className="flex items-center gap-0.5 ">
128
+ {items.map((it, i) => {
129
+ const isActive = i === active;
130
+ return (
131
+ <button
132
+ key={it.id}
133
+ type="button"
134
+ onClick={() => setActive(i)}
135
+ className={cn(
136
+ "relative cursor-pointer rounded-md px-1.5 py-0.5 font-mono outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
137
+ isActive
138
+ ? "text-foreground"
139
+ : "text-muted-foreground hover:bg-accent hover:text-foreground",
140
+ )}
141
+ >
142
+ {isActive && (
143
+ <motion.span
144
+ aria-hidden="true"
145
+ layoutId="pkg-tab-active"
146
+ transition={reduced ? { duration: 0 } : SLIDE_SPRING}
147
+ className="absolute inset-0 rounded-md bg-accent"
148
+ />
149
+ )}
150
+ <span className="relative">{it.id}</span>
151
+ </button>
152
+ );
153
+ })}
154
+ </div>
155
+ <Button
156
+ ref={copyButtonRef}
157
+ variant="ghost"
158
+ size="icon"
159
+ onClick={handleCopy}
160
+ className="ml-auto text-muted-foreground hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
161
+ title="Copy to clipboard"
162
+ aria-label="Copy install command"
163
+ >
164
+ <CopyMorphIcon copied={isCopied} />
165
+ </Button>
166
+ </div>
167
+ )}
168
+
169
+ <div className="flex items-center">
170
+ <div className="flex-1 overflow-x-auto">
171
+ <pre className="flex items-center overflow-x-auto px-5 py-4 font-mono text-foreground">
172
+ <code>
173
+ <TextMorph>{cur.command}</TextMorph>
174
+ </code>
175
+ </pre>
176
+ </div>
177
+ {items.length === 1 && (
178
+ <Button
179
+ ref={copyButtonRef}
180
+ variant="ghost"
181
+ size="icon-sm"
182
+ onClick={handleCopy}
183
+ className="mr-2 text-muted-foreground hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
184
+ title="Copy to clipboard"
185
+ aria-label="Copy install command"
186
+ >
187
+ <CopyMorphIcon copied={isCopied} />
188
+ </Button>
189
+ )}
190
+ </div>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function ScrollFade() {
6
+ const [visible, setVisible] = useState(false);
7
+
8
+ useEffect(() => {
9
+ const handleScroll = () => setVisible(window.scrollY > 50);
10
+ handleScroll();
11
+ window.addEventListener("scroll", handleScroll, { passive: true });
12
+ return () => window.removeEventListener("scroll", handleScroll);
13
+ }, []);
14
+
15
+ return (
16
+ <div
17
+ className={`pointer-events-none fixed inset-x-0 top-0 z-(--z-scroll-fade) h-20 bg-gradient-to-b from-background to-transparent transition-opacity duration-300 ${
18
+ visible ? "opacity-100" : "opacity-0"
19
+ }`}
20
+ />
21
+ );
22
+ }