Loading docs...
A production-grade floating Dynamic Island component engineered with GSAP FLIP morphing animations. Seamlessly transitions between a compact pill and expandable configuration sheets, features a 60 FPS subpixel circular SVG scroll tracker, and provides built-in typography and theme color palette selectors.
1"use client";23import React, {4 useLayoutEffect,5 useEffect,6 useRef,7 useState,8 useCallback,9} from "react";10import gsap from "gsap";1112type PanelType = "font" | "theme" | null;1314export type ThemeOption = {15 name: string;16 primary: string;17 secondary: string;18};1920export const fonts = ["Inter", "Georgia", "Poppins"] as const;2122export const fontFamilies: Record<string, string> = {23 Inter:24 "var(--font-inter), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",25 Georgia: "Georgia, Cambria, 'Times New Roman', Times, serif",26 Poppins:27 "var(--font-poppins), -apple-system, BlinkMacSystemFont, sans-serif",28};2930export const themes: ThemeOption[] = [31 {32 name: "Blue",33 primary: "#4F46E5",34 secondary: "#818CF8",35 },36 {37 name: "Red",38 primary: "#DC2626",39 secondary: "#F87171",40 },41 {42 name: "Sky",43 primary: "#0284C7",44 secondary: "#38BDF8",45 },46];4748export type SectionItem = {49 id: string;50 name: string;51};5253export const DEFAULT_SECTIONS: SectionItem[] = [54 { id: "hero", name: "Home" },55 { id: "features", name: "Features" },56 { id: "architecture", name: "Architecture" },57 { id: "pricing", name: "Pricing" },58 { id: "faq", name: "FAQ" },59];6061/* =========================================62 THEME SWATCH63========================================= */6465function ThemeSwatch({66 primary,67 secondary,68}: {69 primary: string;70 secondary: string;71}) {72 return (73 <svg74 xmlns="http://www.w3.org/2000/svg"75 width="19"76 height="12"77 viewBox="0 0 19 12"78 fill="none"79 aria-hidden="true"80 className="shrink-0"81 >82 <path83 d="M12 6C12 9.31371 9.31371 12 6 12C2.68629 12 0 9.31371 0 6C0 2.68629 2.68629 0 6 0C9.31371 0 12 2.68629 12 6Z"84 fill={primary}85 />8687 <path88 d="M19 6C19 9.31371 16.3137 12 13 12C9.68629 12 7 9.31371 7 6C7 2.68629 9.68629 0 13 0C16.3137 0 19 2.68629 19 6Z"89 fill={secondary}90 />91 </svg>92 );93}9495/* =========================================96 PROGRESS ICON97========================================= */9899function ProgressIcon({100 color,101 progress = 0,102}: {103 color: string;104 progress?: number;105}) {106 const radius = 6.25;107 const circumference = 2 * Math.PI * radius;108109 const safeProgress = Math.min(110 100,111 Math.max(0, progress),112 );113114 const strokeDashoffset =115 circumference -116 (circumference * safeProgress) / 100;117118 return (119 <svg120 xmlns="http://www.w3.org/2000/svg"121 width="16"122 height="16"123 viewBox="0 0 16 16"124 fill="none"125 aria-hidden="true"126 className="shrink-0"127 style={{ shapeRendering: "geometricPrecision" }}128 >129 {/* Track */}130 <circle131 cx="8"132 cy="8"133 r={radius}134 stroke="#333333"135 strokeWidth="1.5"136 />137138 {/* Progress */}139 <circle140 cx="8"141 cy="8"142 r={radius}143 stroke={color}144 strokeWidth="1.5"145 fill="none"146 strokeLinecap="round"147 strokeDasharray={circumference}148 strokeDashoffset={strokeDashoffset}149 transform="rotate(-90 8 8)"150 style={{151 transition:152 "stroke-dashoffset 140ms cubic-bezier(0.25, 0.1, 0.25, 1), stroke 220ms ease",153 willChange: "stroke-dashoffset",154 }}155 />156 </svg>157 );158}159160/* =========================================161 THEME DOT162========================================= */163164function ThemeDot({ color }: { color: string }) {165 return (166 <svg167 xmlns="http://www.w3.org/2000/svg"168 width="12"169 height="12"170 viewBox="0 0 12 12"171 fill="none"172 aria-hidden="true"173 className="shrink-0"174 >175 <circle176 cx="6"177 cy="6"178 r="6"179 fill={color}180 />181 </svg>182 );183}184185/* =========================================186 PROPS187========================================= */188189export interface ProgressPopUpProps {190 sections?: SectionItem[];191 defaultFont?: string;192 defaultTheme?: ThemeOption;193 onFontChange?: (194 font: string,195 fontFamily: string,196 ) => void;197 onThemeChange?: (198 theme: ThemeOption,199 ) => void;200 onSectionChange?: (201 section: string,202 ) => void;203 className?: string;204}205206/* =========================================207 COMPONENT208========================================= */209210export default function ProgressPopUp({211 sections = DEFAULT_SECTIONS,212 defaultFont = "Inter",213 defaultTheme = themes[1],214 onFontChange,215 onThemeChange,216 onSectionChange,217 className = "",218}: ProgressPopUpProps) {219 const [activePanel, setActivePanel] =220 useState<PanelType>(null);221222 const [selectedFont, setSelectedFont] =223 useState(defaultFont);224225 const [selectedTheme, setSelectedTheme] =226 useState<ThemeOption>(defaultTheme);227228 const [scrollProgress, setScrollProgress] =229 useState(0);230231 const [activeSection, setActiveSection] =232 useState(sections[0]?.name || "Home");233234 /* =========================================235 REFS236 ========================================= */237238 const activeSectionRef =239 useRef(sections[0]?.name || "Home");240241 const onSectionChangeRef =242 useRef(onSectionChange);243244 useEffect(() => {245 onSectionChangeRef.current = onSectionChange;246 }, [onSectionChange]);247248 const shellRef =249 useRef<HTMLDivElement>(null);250251 const innerRef =252 useRef<HTMLDivElement>(null);253254 const panelRef =255 useRef<HTMLDivElement>(null);256257 const sectionLabelRef =258 useRef<HTMLParagraphElement>(null);259260 const pillDimensionsRef = useRef({261 width: 0,262 height: 40,263 });264265 const isAnimating =266 useRef(false);267268 /* =========================================269 GET PILL SIZE270 ========================================= */271272 const capturePillSize = useCallback(() => {273 if (274 !shellRef.current ||275 activePanel !== null276 ) {277 return;278 }279280 const rect =281 shellRef.current.getBoundingClientRect();282283 if (284 rect.width > 0 &&285 rect.height > 0286 ) {287 pillDimensionsRef.current = {288 width: Math.ceil(rect.width),289 height: Math.ceil(rect.height),290 };291 }292 }, [activePanel]);293294 /* =========================================295 INITIAL SETUP296 ========================================= */297298 useLayoutEffect(() => {299 const shell = shellRef.current;300 const inner = innerRef.current;301302 if (!shell || !inner) return;303304 capturePillSize();305306 gsap.set(shell, {307 clearProps: "width,height",308 });309310 gsap.set(inner, {311 width: "100%",312 height: "100%",313 });314 }, [capturePillSize]);315316 /* =========================================317 SCROLL TRACKING318 ========================================= */319320 useEffect(() => {321 let ticking = false;322323 const handleScroll = () => {324 if (ticking) return;325326 ticking = true;327328 requestAnimationFrame(() => {329 const docEl =330 document.documentElement;331332 const totalHeight =333 docEl.scrollHeight -334 window.innerHeight;335336 const currentScroll =337 window.scrollY;338339 /* -------------------------340 PROGRESS341 ------------------------- */342343 if (totalHeight > 0) {344 const progress =345 (currentScroll /346 totalHeight) *347 100;348349 setScrollProgress(350 Math.min(351 100,352 Math.max(353 0,354 progress,355 ),356 ),357 );358 } else {359 setScrollProgress(0);360 }361362 /* -------------------------363 SECTION364 ------------------------- */365366 const scrollPosition =367 currentScroll +368 window.innerHeight * 0.35;369370 let currentSectionName =371 sections[0]?.name || "Home";372373 for (374 let i = 0;375 i < sections.length;376 i++377 ) {378 const section =379 document.getElementById(380 sections[i].id,381 );382383 if (!section) continue;384385 const top =386 section.offsetTop;387388 const bottom =389 top + section.offsetHeight;390391 if (392 scrollPosition >= top &&393 scrollPosition <= bottom394 ) {395 currentSectionName =396 sections[i].name;397 break;398 }399400 if (401 scrollPosition >= top402 ) {403 currentSectionName =404 sections[i].name;405 }406 }407408 if (activeSectionRef.current !== currentSectionName) {409 activeSectionRef.current = currentSectionName;410 setActiveSection(currentSectionName);411 onSectionChangeRef.current?.(currentSectionName);412413 if (sectionLabelRef.current) {414 gsap.fromTo(415 sectionLabelRef.current,416 {417 opacity: 0.25,418 y: -2,419 },420 {421 opacity: 1,422 y: 0,423 duration: 0.2,424 ease: "power2.out",425 overwrite: true,426 },427 );428 }429 }430431 ticking = false;432 });433 };434435 handleScroll();436437 window.addEventListener(438 "scroll",439 handleScroll,440 {441 passive: true,442 },443 );444445 window.addEventListener(446 "resize",447 handleScroll,448 );449450 return () => {451 window.removeEventListener(452 "scroll",453 handleScroll,454 );455456 window.removeEventListener(457 "resize",458 handleScroll,459 );460 };461 }, [sections]);462463 /* =========================================464 SCROLL TO SECTION465 ========================================= */466467 const handleScrollToSection = (468 sectionName: string,469 ) => {470 const section = sections.find(471 (item) =>472 item.name === sectionName,473 );474475 if (!section) return;476477 const element =478 document.getElementById(479 section.id,480 );481482 if (!element) return;483484 element.scrollIntoView({485 behavior: "smooth",486 block: "start",487 });488 };489490 /* =========================================491 OPEN PANEL492493 PILL → SHEET494495 This is intentionally the reverse496 choreography of closePanel().497 ========================================= */498499 const openPanel = (500 panel: Exclude<501 PanelType,502 null503 >,504 ) => {505 if (isAnimating.current) {506 return;507 }508509 const shell =510 shellRef.current;511512 const inner =513 innerRef.current;514515 if (!shell || !inner) {516 return;517 }518519 /* -----------------------------------------520 FIRST OPEN521 ----------------------------------------- */522523 if (activePanel === null) {524 capturePillSize();525526 isAnimating.current = true;527528 const pillWidth =529 pillDimensionsRef.current530 .width ||531 shell.getBoundingClientRect()532 .width;533534 const pillHeight =535 pillDimensionsRef.current536 .height || 40;537538 /*539 * Kill anything still running on the540 * geometry before we begin a new morph.541 */542 gsap.killTweensOf([543 shell,544 inner,545 ]);546547 /*548 * Lock pill geometry.549 */550 gsap.set(shell, {551 width: pillWidth,552 height: pillHeight,553 overflow: "hidden",554 });555556 gsap.set(inner, {557 borderRadius: 8,558 });559560 /*561 * Existing pill content.562 *563 * Instead of hard removing it immediately,564 * fade + scale it like reverse close.565 */566 const pillContent =567 inner.firstElementChild;568569 const openingTl =570 gsap.timeline({571 defaults: {572 overwrite: "auto",573 },574 });575576 if (pillContent) {577 openingTl.to(578 pillContent,579 {580 opacity: 0,581 scale: 0.97,582 y: 2,583 duration: 0.13,584 ease: "power2.in",585 },586 );587 }588589 /*590 * Swap React state after the pill591 * has started its exit.592 */593 openingTl.add(() => {594 setActivePanel(panel);595 });596597 /*598 * Wait for React to mount panel.599 */600 openingTl.call(() => {601 requestAnimationFrame(() => {602 const newPanel =603 panelRef.current;604605 if (!newPanel) {606 isAnimating.current =607 false;608 return;609 }610611 const targetWidth =612 Math.ceil(613 newPanel.scrollWidth,614 ) + 2;615616 const targetHeight =617 Math.ceil(618 newPanel.scrollHeight,619 ) + 2;620621 /*622 * Start sheet exactly like the623 * reverse state of close.624 */625 gsap.set(newPanel, {626 opacity: 0,627 scale: 0.975,628 y: 6,629 transformOrigin:630 "50% 50%",631 });632633 /*634 * SHELL MORPH635 */636 gsap.to(shell, {637 width: targetWidth,638 height: targetHeight,639 duration: 0.42,640 ease: "power4.inOut",641 overwrite: true,642 });643644 /*645 * RADIUS MORPH646 */647 gsap.to(inner, {648 borderRadius: 11,649 duration: 0.34,650 ease: "power3.out",651 overwrite: true,652 });653654 /*655 * SHEET ENTER656 */657 gsap.to(newPanel, {658 opacity: 1,659 scale: 1,660 y: 0,661 duration: 0.30,662 delay: 0.05,663 ease: "expo.out",664 overwrite: true,665 onComplete: () => {666 isAnimating.current =667 false;668 },669 });670 });671 });672673 return;674 }675676 /* -----------------------------------------677 SWITCH FONT ↔ THEME678 ----------------------------------------- */679680 if (activePanel === panel) {681 return;682 }683684 isAnimating.current = true;685686 const currentPanel =687 panelRef.current;688689 const switchTl =690 gsap.timeline({691 defaults: {692 overwrite: "auto",693 },694 });695696 /*697 * Old panel leaves.698 */699 if (currentPanel) {700 switchTl.to(701 currentPanel,702 {703 opacity: 0,704 scale: 0.975,705 y: -5,706 duration: 0.13,707 ease: "power2.in",708 },709 );710 }711712 /*713 * Swap panel.714 */715 switchTl.add(() => {716 setActivePanel(panel);717 });718719 /*720 * New panel enters.721 */722 switchTl.call(() => {723 requestAnimationFrame(() => {724 const newPanel =725 panelRef.current;726727 if (728 !newPanel ||729 !shellRef.current730 ) {731 isAnimating.current =732 false;733 return;734 }735736 const targetWidth =737 Math.ceil(738 newPanel.scrollWidth,739 ) + 2;740741 const targetHeight =742 Math.ceil(743 newPanel.scrollHeight,744 ) + 2;745746 gsap.set(newPanel, {747 opacity: 0,748 scale: 0.975,749 y: 6,750 transformOrigin:751 "50% 50%",752 });753754 const switchGeometry =755 gsap.timeline({756 defaults: {757 overwrite: "auto",758 },759 onComplete: () => {760 isAnimating.current =761 false;762 },763 });764765 switchGeometry.to(766 shellRef.current,767 {768 width: targetWidth,769 height: targetHeight,770 duration: 0.32,771 ease: "power4.inOut",772 },773 );774775 switchGeometry.to(776 newPanel,777 {778 opacity: 1,779 scale: 1,780 y: 0,781 duration: 0.24,782 ease: "expo.out",783 },784 "-=0.18",785 );786 });787 });788 };789790 /* =========================================791 CLOSE PANEL792793 SHEET → PILL794========================================= */795796 const closePanel = () => {797 if (isAnimating.current) {798 return;799 }800801 const shell =802 shellRef.current;803804 const inner =805 innerRef.current;806807 const currentPanel =808 panelRef.current;809810 if (!shell || !inner) {811 return;812 }813814 isAnimating.current = true;815816 const pillWidth =817 pillDimensionsRef.current818 .width || 240;819820 const pillHeight =821 pillDimensionsRef.current822 .height || 40;823824 const closingTl =825 gsap.timeline({826 defaults: {827 overwrite: "auto",828 },829 onComplete: () => {830 /*831 * React switches sheet → pill.832 */833 setActivePanel(null);834835 /*836 * Once pill is mounted, release837 * explicit geometry.838 */839 requestAnimationFrame(() => {840 if (841 !shellRef.current ||842 !innerRef.current843 ) {844 isAnimating.current =845 false;846 return;847 }848849 gsap.set(850 shellRef.current,851 {852 clearProps:853 "width,height",854 },855 );856857 gsap.set(858 innerRef.current,859 {860 clearProps:861 "borderRadius",862 },863 );864865 isAnimating.current =866 false;867 });868 },869 });870871 /*872 * SHEET EXIT873 */874 if (currentPanel) {875 closingTl.to(876 currentPanel,877 {878 opacity: 0,879 scale: 0.975,880 y: 5,881 duration: 0.13,882 ease: "power2.in",883 },884 );885 }886887 /*888 * SHELL CONTRACT889 */890 closingTl.to(891 shell,892 {893 width: pillWidth,894 height: pillHeight,895 duration: 0.42,896 ease: "power4.inOut",897 },898 "-=0.02",899 );900901 /*902 * RADIUS CONTRACT903 */904 closingTl.to(905 inner,906 {907 borderRadius: 8,908 duration: 0.3,909 ease: "power3.out",910 },911 "<",912 );913 };914915 /* =========================================916 FONT SELECT917========================================= */918919 const handleFontSelect = (920 font: string,921 ) => {922 const option =923 document.querySelector(924 `[data-font-option="${font}"]`,925 ) as HTMLElement | null;926927 if (option) {928 gsap.to(option, {929 scale: 0.97,930 duration: 0.08,931 ease: "power2.out",932 overwrite: true,933 yoyo: true,934 repeat: 1,935 });936 }937938 setSelectedFont(font);939940 onFontChange?.(941 font,942 fontFamilies[font] || font,943 );944945 /*946 * Tiny pause so selected state947 * visually registers before close.948 */949 window.setTimeout(() => {950 closePanel();951 }, 90);952 };953954 /* =========================================955 THEME SELECT956========================================= */957958 const handleThemeSelect = (959 theme: ThemeOption,960 ) => {961 const option =962 document.querySelector(963 `[data-theme-option="${theme.name}"]`,964 ) as HTMLElement | null;965966 if (option) {967 gsap.to(option, {968 scale: 0.97,969 duration: 0.08,970 ease: "power2.out",971 overwrite: true,972 yoyo: true,973 repeat: 1,974 });975 }976977 setSelectedTheme(theme);978979 onThemeChange?.(theme);980981 window.setTimeout(() => {982 closePanel();983 }, 90);984 };985986 const currentFontFamily =987 fontFamilies[selectedFont] ||988 selectedFont;989990 /* =========================================991 RENDER992========================================= */993994 return (995 <div996 className={`relative select-none ${className}`}997 style={{998 fontFamily:999 currentFontFamily,1000 }}1001 >1002 <div1003 ref={shellRef}1004 className="flex h-fit w-fit overflow-hidden rounded-lg bg-neutral-900 p-px"1005 onMouseEnter={1006 capturePillSize1007 }1008 >1009 <div1010 ref={innerRef}1011 className="h-full w-full overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"1012 >1013 {/* =====================================1014 PILL1015 ====================================== */}10161017 {activePanel === null && (1018 <div className="flex h-10 w-fit items-center gap-1 p-1">1019 {/* FONT */}10201021 <button1022 type="button"1023 onClick={() =>1024 openPanel("font")1025 }1026 className="flex h-8 items-center justify-center rounded-lg px-2.5 transition-colors duration-150 hover:bg-neutral-800 focus:outline-none"1027 >1028 <p1029 className="text-sm font-medium text-neutral-50"1030 style={{1031 fontFamily:1032 currentFontFamily,1033 }}1034 >1035 {selectedFont}1036 </p>1037 </button>10381039 {/* DIVIDER */}10401041 <div className="h-6 border-l border-dashed border-neutral-600" />10421043 {/* PROGRESS */}10441045 <button1046 type="button"1047 onClick={() =>1048 handleScrollToSection(1049 activeSection,1050 )1051 }1052 className="flex h-8 items-center justify-center gap-2 rounded-lg px-2.5 transition-colors duration-150 hover:bg-neutral-800/60 focus:outline-none"1053 title={`${activeSection} (${Math.round(scrollProgress)}%)`}1054 >1055 <ProgressIcon1056 color={1057 selectedTheme.primary1058 }1059 progress={1060 scrollProgress1061 }1062 />10631064 <p1065 ref={1066 sectionLabelRef1067 }1068 className="text-sm font-medium text-neutral-50"1069 >1070 {activeSection}1071 </p>1072 </button>10731074 {/* DIVIDER */}10751076 <div className="h-6 border-l border-dashed border-neutral-600" />10771078 {/* THEME */}10791080 <button1081 type="button"1082 onClick={() =>1083 openPanel("theme")1084 }1085 className="flex h-8 items-center justify-center gap-2 rounded-lg px-2.5 transition-colors duration-150 hover:bg-neutral-800 focus:outline-none"1086 >1087 <ThemeDot1088 color={1089 selectedTheme.primary1090 }1091 />10921093 <p className="text-sm font-medium text-neutral-50">1094 {selectedTheme.name}1095 </p>1096 </button>1097 </div>1098 )}10991100 {/* =====================================1101 FONT SHEET1102 ====================================== */}11031104 {activePanel === "font" && (1105 <div1106 ref={panelRef}1107 className="flex w-[178px] flex-col gap-0.5 p-1 opacity-0"1108 >1109 {fonts.map((font) => {1110 const selected =1111 selectedFont === font;11121113 return (1114 <button1115 key={font}1116 type="button"1117 data-font-option={font}1118 onClick={() =>1119 handleFontSelect(1120 font,1121 )1122 }1123 className={`flex h-8 items-center justify-between rounded-md px-2 transition-colors duration-150 focus:outline-none ${1124 selected1125 ? "bg-neutral-800 text-neutral-50"1126 : "text-neutral-300 hover:bg-neutral-800 hover:text-neutral-50"1127 }`}1128 >1129 <span1130 className="text-sm font-medium"1131 style={{1132 fontFamily:1133 fontFamilies[1134 font1135 ] || font,1136 }}1137 >1138 {font}1139 </span>11401141 {/* DOT ONLY FOR FONT */}11421143 {selected && (1144 <span1145 className="h-1.5 w-1.5 rounded-full"1146 style={{1147 backgroundColor:1148 selectedTheme.primary,1149 }}1150 />1151 )}1152 </button>1153 );1154 })}1155 </div>1156 )}11571158 {/* =====================================1159 THEME SHEET1160 ====================================== */}11611162 {activePanel === "theme" && (1163 <div1164 ref={panelRef}1165 className="flex w-[178px] flex-col gap-0.5 p-1 opacity-0"1166 >1167 {themes.map((theme) => {1168 const selected =1169 selectedTheme.name ===1170 theme.name;11711172 return (1173 <button1174 key={theme.name}1175 type="button"1176 data-theme-option={1177 theme.name1178 }1179 onClick={() =>1180 handleThemeSelect(1181 theme,1182 )1183 }1184 className={`flex h-8 items-center justify-between rounded-md px-2 transition-colors duration-150 focus:outline-none ${1185 selected1186 ? "bg-neutral-800 text-neutral-50"1187 : "text-neutral-300 hover:bg-neutral-800 hover:text-neutral-50"1188 }`}1189 >1190 <span className="text-sm font-medium">1191 {theme.name}1192 </span>11931194 {/* SWATCH ONLY — NO DOT */}11951196 <ThemeSwatch1197 primary={1198 theme.primary1199 }1200 secondary={1201 theme.secondary1202 }1203 />1204 </button>1205 );1206 })}1207 </div>1208 )}1209 </div>1210 </div>1211 </div>1212 );1213}
pnpm dlx shadcn@latest add https://udayui.vercel.app/r/progress-popup.json| Prop | Type | Default | Description |
|---|---|---|---|
| sections | SectionItem[] | DEFAULT_SECTIONS | Array of section definitions ({ id: string; name: string }) to track with scroll spy and allow anchor navigation. |
| defaultFont | string | "Inter" | Initial selected font family key corresponding to supported typography options. |
| defaultTheme | ThemeOption | themes[1] | Initial selected theme palette object ({ name, primary, secondary }). |
| onFontChange | (font: string, fontFamily: string) => void | undefined | Callback invoked when the user selects a font option from the typography sheet. |
| onThemeChange | (theme: ThemeOption) => void | undefined | Callback invoked when the user selects a color palette from the theme sheet. |
| onSectionChange | (section: string) => void | undefined | Callback invoked when the scroll spy detects and activates a new section. |
| className | string | "" | Additional CSS class names to apply to the root wrapper element. |