Skip to content

Commit 2334b10

Browse files
Merge pull request #45 from pantharshit007/feat/scroll-indicator
2 parents 3aea09e + c5bc68a commit 2334b10

File tree

13 files changed

+404
-20
lines changed

13 files changed

+404
-20
lines changed

Todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
- [ ] fix the `after:bg-background/90` class to be dynamic, so that it can be changed by the user via the component props.
22
- [ ] there is a bug in the `MorphingCard` component, where the Icon prop doesn't work if the component is called into is not a 'use client'.
33
- [ ] check mobile responsiveness of the components previous to feedback modal.
4-
- [ ] update the manual hook installation for respective comp. (steps)
4+
- [ ] [IMP] update the manual hook installation for respective comp. (steps)
55
- [ ] fix scroll bar in manual boxes and code boxes (either make them small or remove them).
66
- [ ] do we need bread crumbs for sub headings?
77
- [ ] update docs to `Fuma docs`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "aether-ui",
3-
"version": "0.1.4",
3+
"version": "0.1.5",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack",

public/c/scroll-indicator.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "scroll-indicator",
3+
"type": "registry:ui",
4+
"registryDependencies": [],
5+
"title": "Scroll Indicator",
6+
"author": "Harshit Pant <hrshit.in>",
7+
"description": "A scroll indicator which shows scroll progress on page with visual cues to indicate page percentage coverage.",
8+
"dependencies": [],
9+
"files": [
10+
{
11+
"path": "scroll-indicator.tsx",
12+
"content": "\"use client\";\r\n\r\nimport { useEffect, useState, useCallback } from \"react\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nimport { useMobile } from \"@/hooks/use-mobile\";\r\n\r\nexport interface ScrollIndicatorProps {\r\n showOnMobile?: boolean;\r\n showCornerIndicator?: boolean;\r\n showTopBar?: boolean;\r\n topBarClassName?: string;\r\n topBarGradient?: string;\r\n indicatorClassName?: string;\r\n indicatorSvgClassName?: string;\r\n indicatorPercentageClassName?: string;\r\n indicatorArrowClassName?: string;\r\n}\r\n\r\nexport function ScrollIndicator({\r\n showOnMobile = false,\r\n showCornerIndicator = true,\r\n showTopBar = false,\r\n topBarClassName,\r\n topBarGradient,\r\n indicatorClassName,\r\n indicatorSvgClassName,\r\n indicatorPercentageClassName,\r\n indicatorArrowClassName,\r\n}: ScrollIndicatorProps) {\r\n const [scrollPercentage, setScrollPercentage] = useState(0);\r\n const [isVisible, setIsVisible] = useState(false);\r\n const [isHovered, setIsHovered] = useState(false);\r\n const isMobile = useMobile();\r\n\r\n const calculateScrollPercentage = useCallback(() => {\r\n const windowHeight = window.innerHeight;\r\n const documentHeight = document.documentElement.scrollHeight - windowHeight;\r\n const scrolled = window.scrollY;\r\n const percentage = (scrolled / documentHeight) * 100;\r\n\r\n setScrollPercentage(Math.min(100, Math.max(0, percentage)));\r\n setIsVisible(scrolled > windowHeight - windowHeight / 3);\r\n }, []);\r\n\r\n useEffect(() => {\r\n calculateScrollPercentage();\r\n\r\n let ticking = false;\r\n const handleScroll = () => {\r\n if (!ticking) {\r\n window.requestAnimationFrame(() => {\r\n calculateScrollPercentage();\r\n ticking = false;\r\n });\r\n ticking = true;\r\n }\r\n };\r\n\r\n window.addEventListener(\"scroll\", handleScroll, { passive: true });\r\n window.addEventListener(\"resize\", calculateScrollPercentage, { passive: true });\r\n\r\n return () => {\r\n window.removeEventListener(\"scroll\", handleScroll);\r\n window.removeEventListener(\"resize\", calculateScrollPercentage);\r\n };\r\n }, [calculateScrollPercentage]);\r\n\r\n const scrollToTop = () => {\r\n window.scrollTo({\r\n top: 0,\r\n behavior: \"smooth\",\r\n });\r\n };\r\n\r\n const circumference = 2 * Math.PI * 42; // radius = 42 : 2*pi*r\r\n const strokeDashoffset = circumference - (scrollPercentage / 100) * circumference;\r\n\r\n if (isMobile && !showOnMobile) {\r\n return null;\r\n }\r\n\r\n return (\r\n <>\r\n {showTopBar && (\r\n <div className={cn(\"fixed top-0 left-0 z-50 h-1 w-full bg-gray-900/20\", topBarClassName)}>\r\n <div\r\n className={cn(\r\n \"h-full bg-gradient-to-r from-cyan-300 to-cyan-600 transition-all duration-300 ease-out\",\r\n topBarGradient\r\n )}\r\n style={{ width: `${scrollPercentage}%` }}\r\n />\r\n </div>\r\n )}\r\n\r\n {showCornerIndicator && (\r\n <button\r\n onClick={scrollToTop}\r\n onMouseEnter={() => setIsHovered(true)}\r\n onMouseLeave={() => setIsHovered(false)}\r\n className={cn(\r\n \"fixed right-4 bottom-4 z-50 flex h-15 w-15 items-center justify-center rounded-full bg-zinc-900 shadow-2xl transition-all duration-500 ease-out hover:scale-110\",\r\n isVisible\r\n ? \"translate-x-0 opacity-100\"\r\n : \"pointer-events-none translate-x-32 opacity-0\",\r\n indicatorClassName\r\n )}\r\n aria-label=\"Scroll to top\"\r\n >\r\n {/* SVG Circle Progress */}\r\n <svg className=\"absolute inset-0 h-full w-full -rotate-90\" viewBox=\"0 0 100 100\">\r\n <circle\r\n cx=\"50\"\r\n cy=\"50\"\r\n r=\"42\"\r\n fill=\"none\"\r\n stroke=\"rgba(255, 255, 255, 0.1)\"\r\n strokeWidth=\"4\"\r\n />\r\n {/* Progress circle */}\r\n <circle\r\n cx=\"50\"\r\n cy=\"50\"\r\n r=\"42\"\r\n fill=\"none\"\r\n strokeWidth=\"4\"\r\n strokeLinecap=\"round\"\r\n strokeDasharray={circumference}\r\n strokeDashoffset={strokeDashoffset}\r\n className={cn(\r\n \"stroke-cyan-400 transition-all duration-300 ease-out\",\r\n indicatorSvgClassName\r\n )}\r\n />\r\n </svg>\r\n\r\n <div className=\"relative flex items-center justify-center\">\r\n <span\r\n className={cn(\r\n \"text-sm font-bold text-white transition-all duration-300\",\r\n isHovered ? \"scale-0 opacity-0\" : \"scale-100 opacity-100\",\r\n indicatorPercentageClassName\r\n )}\r\n >\r\n {Math.round(scrollPercentage)}%\r\n </span>\r\n\r\n <svg\r\n className={cn(\r\n \"absolute h-6 w-6 text-cyan-400 transition-all duration-300\",\r\n isHovered ? \"scale-100 opacity-100\" : \"scale-0 opacity-0\",\r\n indicatorArrowClassName\r\n )}\r\n fill=\"none\"\r\n viewBox=\"0 0 24 24\"\r\n stroke=\"currentColor\"\r\n strokeWidth={2}\r\n >\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 10l7-7m0 0l7 7m-7-7v18\" />\r\n </svg>\r\n </div>\r\n </button>\r\n )}\r\n </>\r\n );\r\n}\r\n\r\n// DevelopedBy: AetherUI\r\n",
13+
"type": "registry:ui",
14+
"target": "components/content/scroll-indicator.tsx"
15+
},
16+
{
17+
"path": "hooks/use-mobile.ts",
18+
"content": "import React from \"react\";\r\n\r\nconst MOBILE_BREAKPOINT = 768;\r\n\r\nexport function useMobile() {\r\n const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);\r\n\r\n React.useEffect(() => {\r\n const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\r\n const onChange = () => {\r\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n };\r\n mql.addEventListener(\"change\", onChange);\r\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n return () => mql.removeEventListener(\"change\", onChange);\r\n }, []);\r\n\r\n return !!isMobile;\r\n}\r\n",
19+
"type": "registry:hook",
20+
"target": "hooks/use-mobile.ts"
21+
}
22+
]
23+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "scroll-indicator-demo",
3+
"type": "registry:component",
4+
"description": "A scroll indicator component that shows scroll progress and allows scrolling to top.",
5+
"componentName": "scroll-indicator-demo",
6+
"files": [
7+
{
8+
"path": "scroll-indicator-demo.tsx",
9+
"content": "import { ScrollIndicator } from \"@/components/content/scroll-indicator\";\r\nimport React from \"react\";\r\n\r\nconst ScrollIndicatorDemo = () => {\r\n return (\r\n <div className=\"bg-background\">\r\n <div className=\"container mx-auto px-4 py-10\">\r\n <h1 className=\"mb-8 text-4xl font-bold\">Scroll Indicator Demo</h1>\r\n\r\n <div className=\"space-y-8\">\r\n {Array.from({ length: 10 }).map((_, i) => (\r\n <div key={i} className=\"bg-card rounded-lg border p-8\">\r\n <h2 className=\"mb-4 text-2xl font-semibold\">Section {i + 1}</h2>\r\n <p className=\"text-muted-foreground\">\r\n Scroll down to see the scroll indicator appear after you pass the first viewport\r\n height. The circular progress shows your scroll percentage, and hovering reveals an\r\n arrow to scroll back to top.\r\n </p>\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n\r\n <ScrollIndicator showTopBar />\r\n </div>\r\n );\r\n};\r\n\r\nexport default ScrollIndicatorDemo;\r\n",
10+
"type": "registry:component",
11+
"target": "components/scroll-indicator-demo.tsx"
12+
},
13+
{
14+
"path": "components/content/scroll-indicator.tsx",
15+
"content": "\"use client\";\r\n\r\nimport { useEffect, useState, useCallback } from \"react\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nimport { useMobile } from \"@/hooks/use-mobile\";\r\n\r\nexport interface ScrollIndicatorProps {\r\n showOnMobile?: boolean;\r\n showCornerIndicator?: boolean;\r\n showTopBar?: boolean;\r\n topBarClassName?: string;\r\n topBarGradient?: string;\r\n indicatorClassName?: string;\r\n indicatorSvgClassName?: string;\r\n indicatorPercentageClassName?: string;\r\n indicatorArrowClassName?: string;\r\n}\r\n\r\nexport function ScrollIndicator({\r\n showOnMobile = false,\r\n showCornerIndicator = true,\r\n showTopBar = false,\r\n topBarClassName,\r\n topBarGradient,\r\n indicatorClassName,\r\n indicatorSvgClassName,\r\n indicatorPercentageClassName,\r\n indicatorArrowClassName,\r\n}: ScrollIndicatorProps) {\r\n const [scrollPercentage, setScrollPercentage] = useState(0);\r\n const [isVisible, setIsVisible] = useState(false);\r\n const [isHovered, setIsHovered] = useState(false);\r\n const isMobile = useMobile();\r\n\r\n const calculateScrollPercentage = useCallback(() => {\r\n const windowHeight = window.innerHeight;\r\n const documentHeight = document.documentElement.scrollHeight - windowHeight;\r\n const scrolled = window.scrollY;\r\n const percentage = (scrolled / documentHeight) * 100;\r\n\r\n setScrollPercentage(Math.min(100, Math.max(0, percentage)));\r\n setIsVisible(scrolled > windowHeight - windowHeight / 3);\r\n }, []);\r\n\r\n useEffect(() => {\r\n calculateScrollPercentage();\r\n\r\n let ticking = false;\r\n const handleScroll = () => {\r\n if (!ticking) {\r\n window.requestAnimationFrame(() => {\r\n calculateScrollPercentage();\r\n ticking = false;\r\n });\r\n ticking = true;\r\n }\r\n };\r\n\r\n window.addEventListener(\"scroll\", handleScroll, { passive: true });\r\n window.addEventListener(\"resize\", calculateScrollPercentage, { passive: true });\r\n\r\n return () => {\r\n window.removeEventListener(\"scroll\", handleScroll);\r\n window.removeEventListener(\"resize\", calculateScrollPercentage);\r\n };\r\n }, [calculateScrollPercentage]);\r\n\r\n const scrollToTop = () => {\r\n window.scrollTo({\r\n top: 0,\r\n behavior: \"smooth\",\r\n });\r\n };\r\n\r\n const circumference = 2 * Math.PI * 42; // radius = 42 : 2*pi*r\r\n const strokeDashoffset = circumference - (scrollPercentage / 100) * circumference;\r\n\r\n if (isMobile && !showOnMobile) {\r\n return null;\r\n }\r\n\r\n return (\r\n <>\r\n {showTopBar && (\r\n <div className={cn(\"fixed top-0 left-0 z-50 h-1 w-full bg-gray-900/20\", topBarClassName)}>\r\n <div\r\n className={cn(\r\n \"h-full bg-gradient-to-r from-cyan-300 to-cyan-600 transition-all duration-300 ease-out\",\r\n topBarGradient\r\n )}\r\n style={{ width: `${scrollPercentage}%` }}\r\n />\r\n </div>\r\n )}\r\n\r\n {showCornerIndicator && (\r\n <button\r\n onClick={scrollToTop}\r\n onMouseEnter={() => setIsHovered(true)}\r\n onMouseLeave={() => setIsHovered(false)}\r\n className={cn(\r\n \"fixed right-4 bottom-4 z-50 flex h-15 w-15 items-center justify-center rounded-full bg-zinc-900 shadow-2xl transition-all duration-500 ease-out hover:scale-110\",\r\n isVisible\r\n ? \"translate-x-0 opacity-100\"\r\n : \"pointer-events-none translate-x-32 opacity-0\",\r\n indicatorClassName\r\n )}\r\n aria-label=\"Scroll to top\"\r\n >\r\n {/* SVG Circle Progress */}\r\n <svg className=\"absolute inset-0 h-full w-full -rotate-90\" viewBox=\"0 0 100 100\">\r\n <circle\r\n cx=\"50\"\r\n cy=\"50\"\r\n r=\"42\"\r\n fill=\"none\"\r\n stroke=\"rgba(255, 255, 255, 0.1)\"\r\n strokeWidth=\"4\"\r\n />\r\n {/* Progress circle */}\r\n <circle\r\n cx=\"50\"\r\n cy=\"50\"\r\n r=\"42\"\r\n fill=\"none\"\r\n strokeWidth=\"4\"\r\n strokeLinecap=\"round\"\r\n strokeDasharray={circumference}\r\n strokeDashoffset={strokeDashoffset}\r\n className={cn(\r\n \"stroke-cyan-400 transition-all duration-300 ease-out\",\r\n indicatorSvgClassName\r\n )}\r\n />\r\n </svg>\r\n\r\n <div className=\"relative flex items-center justify-center\">\r\n <span\r\n className={cn(\r\n \"text-sm font-bold text-white transition-all duration-300\",\r\n isHovered ? \"scale-0 opacity-0\" : \"scale-100 opacity-100\",\r\n indicatorPercentageClassName\r\n )}\r\n >\r\n {Math.round(scrollPercentage)}%\r\n </span>\r\n\r\n <svg\r\n className={cn(\r\n \"absolute h-6 w-6 text-cyan-400 transition-all duration-300\",\r\n isHovered ? \"scale-100 opacity-100\" : \"scale-0 opacity-0\",\r\n indicatorArrowClassName\r\n )}\r\n fill=\"none\"\r\n viewBox=\"0 0 24 24\"\r\n stroke=\"currentColor\"\r\n strokeWidth={2}\r\n >\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 10l7-7m0 0l7 7m-7-7v18\" />\r\n </svg>\r\n </div>\r\n </button>\r\n )}\r\n </>\r\n );\r\n}\r\n\r\n// DevelopedBy: AetherUI\r\n",
16+
"type": "registry:ui",
17+
"target": "components/content/scroll-indicator.tsx"
18+
},
19+
{
20+
"path": "hooks/use-mobile.ts",
21+
"content": "import React from \"react\";\r\n\r\nconst MOBILE_BREAKPOINT = 768;\r\n\r\nexport function useMobile() {\r\n const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);\r\n\r\n React.useEffect(() => {\r\n const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\r\n const onChange = () => {\r\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n };\r\n mql.addEventListener(\"change\", onChange);\r\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n return () => mql.removeEventListener(\"change\", onChange);\r\n }, []);\r\n\r\n return !!isMobile;\r\n}\r\n",
22+
"type": "registry:hook",
23+
"target": "hooks/use-mobile.ts"
24+
}
25+
]
26+
}

scripts/registery-components.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,21 @@ export const components: ComponentDefinition[] = [
140140
},
141141
],
142142
},
143+
{
144+
name: "scroll-indicator",
145+
path: path.join(process.cwd(), "src", "components", "content", "scroll-indicator.tsx"),
146+
registryDependencies: [],
147+
title: "Scroll Indicator",
148+
dependencies: [],
149+
author: "Harshit Pant <hrshit.in>",
150+
description:
151+
"A scroll indicator which shows scroll progress on page with visual cues to indicate page percentage coverage.",
152+
files: [
153+
{
154+
name: "hooks/use-mobile.ts",
155+
path: path.join(process.cwd(), "src", "hooks", "use-mobile.ts"),
156+
type: "registry:hook",
157+
},
158+
],
159+
},
143160
];

scripts/registery-examples.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,4 +420,32 @@ export const examples: Record<string, ComponentDefinition[]> = {
420420
],
421421
},
422422
],
423+
"scroll-indicator": [
424+
{
425+
name: "scroll-indicator-demo",
426+
path: path.join(
427+
process.cwd(),
428+
"src",
429+
"app",
430+
"docs",
431+
"scroll-indicator",
432+
"scroll-indicator-demo.tsx"
433+
),
434+
description:
435+
"A scroll indicator component that shows scroll progress and allows scrolling to top.",
436+
componentName: "scroll-indicator",
437+
files: [
438+
{
439+
name: "scroll-indicator.tsx",
440+
path: path.join(process.cwd(), "src", "components", "content", "scroll-indicator.tsx"),
441+
type: "registry:ui",
442+
},
443+
{
444+
name: "hooks/use-mobile.ts",
445+
path: path.join(process.cwd(), "src", "hooks", "use-mobile.ts"),
446+
type: "registry:hook",
447+
},
448+
],
449+
},
450+
],
423451
};

0 commit comments

Comments
 (0)