feat(ui): implement consistent skeleton loading UI with shimmer animation across key sections#316
Conversation
📝 WalkthroughWalkthroughThis PR implements skeleton loading screens and loading states across the application. It introduces a new Skeleton component library with 8 reusable skeleton UI components, a custom hook for managing loading states with configurable delay, and applies these to 5 major sections (Hero, About, Features, TechStack, DownloadApp). Global CSS variables, dark mode support, and route-level loading UI are also added. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Hi maintainers, I have completed the UI/UX enhancement for consistent skeleton loading across the application. This implementation introduces reusable skeleton components and route-level loading to eliminate layout shifts and blank regions during content loading. It improves perceived performance while maintaining accessibility, responsiveness, and dark mode support. The build passes without warnings, and the changes have been tested locally. Looking forward to your feedback. Happy to make any refinements if required. |
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/components/Hero/Hero.jsx (1)
1-14:⚠️ Potential issue | 🔴 CriticalMissing
"use client"directive in five components — this will throw a build error in Next.js App Router.
Hero.jsx,About.jsx,DownloadApp.jsx,Features.jsx, andTechStack.jsxall calluseSkeletonLoading(), which internally usesuseStateanduseEffect. By default, all components in the App Router (app/directory) are Server Components unless marked otherwise. Using a React client hook in a Server Component is an error; you must mark each component using the hook as a Client Component by adding"use client"at the top of the file.🔧 Fix required for all five files
Add this line to the top of each file, before any imports:
"use client";Example for Hero.jsx:
+"use client"; + import "./Hero.css"; import { FaGithub, FaArrowRight } from "react-icons/fa";Apply the same change to
About.jsx,DownloadApp.jsx,Features.jsx, andTechStack.jsx.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/Hero/Hero.jsx` around lines 1 - 14, The components Hero, About, DownloadApp, Features, and TechStack call the client hook useSkeletonLoading (which uses useState/useEffect) but are currently server components; add the React Client Component directive by inserting "use client" as the very first line of each file (before any imports) so Hero (component Hero), About, DownloadApp, Features, and TechStack can legally call useSkeletonLoading.
🧹 Nitpick comments (3)
app/components/Skeleton/index.js (1)
1-10: Consider exportinguseSkeletonLoadingfrom the barrel.All consumers currently need two distinct import paths —
../Skeletonfor components and../Skeleton/useSkeletonLoadingfor the hook. Exporting the hook fromindex.jsprovides a single entry point consistent with a module barrel pattern.♻️ Proposed addition
export { SkeletonText, SkeletonTitle, SkeletonButton, SkeletonImage, SkeletonCircle, SkeletonCard, SkeletonRow, SkeletonIcon, } from "./Skeleton"; +export { useSkeletonLoading } from "./useSkeletonLoading";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/Skeleton/index.js` around lines 1 - 10, Add the hook to the barrel export so consumers can import everything from the same entry; update the export in index.js to also re-export useSkeletonLoading (e.g., export { useSkeletonLoading } from "./useSkeletonLoading" or export { default as useSkeletonLoading } from "./useSkeletonLoading" depending on the hook's export style) alongside the existing SkeletonText, SkeletonTitle, etc., ensuring the symbol name useSkeletonLoading matches the hook file.app/components/Skeleton/Skeleton.module.css (1)
31-39: Dead CSS:.skeleton-text,.skeleton-text:last-child,.skeleton-avatar, and.skeleton-circleare never applied.
SkeletonTextapplies onlystyles.skeletonper line, reproducing the height/margin/width logic entirely through inline styles —.skeleton-textand its:last-childvariant are never used.- There is no
SkeletonAvatarcomponent, so.skeleton-avataris unreachable.SkeletonCircleuses inline styles for dimensions/radius, bypassing.skeleton-circle.Either apply the CSS classes in the components (removing the inline duplication) or delete the unused rules.
Also applies to: 59-63, 80-84
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/Skeleton/Skeleton.module.css` around lines 31 - 39, The CSS rules for .skeleton-text (and its :last-child), .skeleton-avatar, and .skeleton-circle are dead because the components use inline styles (SkeletonText uses styles.skeleton per line and SkeletonCircle/SkeletonAvatar set dimensions inline) or the component doesn't exist; update components to apply these CSS classes instead of duplicating the styles inline or remove the unused CSS rules. Specifically, in the SkeletonText component replace per-line inline height/margin/width logic by applying styles.skeleton-text and relying on .skeleton-text:last-child for the last-item rule (or remove the CSS and keep inline styles), remove .skeleton-avatar if there is no SkeletonAvatar component (or add the component and apply styles.skeleton-avatar), and either have SkeletonCircle apply styles.skeleton-circle for radius/dimensions or delete the .skeleton-circle rule so the stylesheet and components remain consistent.app/components/Features/Features.jsx (1)
41-53:featureSkeletonsis computed unconditionally on every renderThe
featureSkeletonsarray is built outside theif (isLoading)guard. After the 1.5s window, every re-render allocates and discards these nodes unnecessarily. Moving it inside the guard (or lazily computing it) avoids the waste.♻️ Proposed refactor
- const featureSkeletons = [1, 2, 3, 4].map((id) => ( - <div - key={id} - className={`feature-item ${id % 2 === 0 ? "reverse" : ""}`} - > - <div className="feature-image"> - <SkeletonImage aspectRatio="4/3" /> - </div> - <div className="feature-content"> - <SkeletonText lines={2} /> - </div> - </div> - )); - if (isLoading) { - return <section className="features">{featureSkeletons}</section>; + const featureSkeletons = [1, 2, 3, 4].map((id) => ( + <div + key={id} + className={`feature-item ${id % 2 === 0 ? "reverse" : ""}`} + > + <div className="feature-image"> + <SkeletonImage aspectRatio="4/3" /> + </div> + <div className="feature-content"> + <SkeletonText lines={2} /> + </div> + </div> + )); + return <section className="features">{featureSkeletons}</section>; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/Features/Features.jsx` around lines 41 - 53, The featureSkeletons array is being allocated on every render even when not needed; move its computation inside the isLoading conditional (or compute lazily) so it’s only created when rendering the loading state. Update the component to only build featureSkeletons within the block that checks isLoading (referencing featureSkeletons and isLoading in Features.jsx), or replace the current top-level map with a small helper function (e.g., renderFeatureSkeletons) and call it only when isLoading is true so unnecessary allocations are avoided.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/components/About/About.jsx`:
- Line 10: The hook useSkeletonLoading is forcing a 1.5s artificial loading
state for static components (About, TechStack, Features, DownloadApp) causing
unnecessary delay and SSR hydration flashes; update either the hook or its
usages so static pages opt out: change useSkeletonLoading to accept an optional
parameter (e.g., dataReady boolean or Promise or a duration) and return false
immediately when dataReady === true (or when no duration is provided), or simply
remove/replace the hook call in the listed components with isLoading = false for
static content; ensure the symbol useSkeletonLoading is updated and all
references in About, TechStack, Features, and DownloadApp are modified to pass a
readiness flag or bypass the timeout behavior to avoid client-side skeleton
flashes.
- Around line 22-27: The skeleton for the social links renders four
<SkeletonIcon /> elements but the real social-links list renders five anchors
(FaEnvelope, SiGitlab, FaGithub, FaDiscord, BsTwitterX), causing a layout shift;
update the social-links skeleton in About.jsx to render five placeholders
(either add one more <SkeletonIcon /> or iterate the same source array used for
the real links) so the skeleton structure matches the actual icons and prevents
the transition shift.
- Around line 12-31: The skeleton loading section returned when isLoading is
true lacks ARIA loading attributes; update the JSX in the About component's
isLoading branch (the <section className="about"> returned by the isLoading
check) to include aria-busy="true", role="status", and a descriptive aria-label
(e.g., "Loading about content") so screen-readers know this is a loading
placeholder; keep the existing visual skeleton elements unchanged.
In `@app/components/DownloadApp/DownloadApp.jsx`:
- Around line 3-7: This file is missing the React Server Components client
directive; add the `"use client"` pragma as the very first line of
DownloadApp.jsx (before any imports) so the DownloadApp component and its hook
useSkeletonLoading run on the client; ensure the directive is present and
mirrors the fix applied in About.jsx to avoid server/client mismatches for
DownloadApp, useSkeletonLoading, SkeletonTitle, SkeletonButton, SkeletonImage,
and SkeletonText.
In `@app/components/Features/Features.jsx`:
- Around line 6-7: This file defines the Features component and imports
client-only hooks/components (useSkeletonLoading, SkeletonImage, SkeletonText)
but is missing the React Server Components opt-in; add the "use client"
directive as the very first line of Features.jsx (before any imports) so the
Features component and its use of useSkeletonLoading/SkeletonImage/SkeletonText
run on the client; also apply the same "use client" addition to the related
component referenced at line 39 that uses these client-only symbols.
In `@app/components/Hero/Hero.jsx`:
- Around line 16-38: The skeleton <section className="hero"> rendered when
isLoading is true lacks ARIA context; update the JSX in the Hero component so
that when isLoading is true the <section> includes aria-busy="true" and a
descriptive aria-label (e.g., "Loading hero content"), and ensure the resolved
(non-loading) render clears aria-busy (set to "false" or remove the attribute)
so screen readers know loading has finished; locate the conditional branch that
checks isLoading in Hero.jsx to make this change.
In `@app/components/Skeleton/Skeleton.jsx`:
- Around line 3-88: The rendered skeleton divs are missing aria-hidden="true",
causing screen readers to announce meaningless regions; update each component to
add aria-hidden="true" on the root element: for SkeletonText and SkeletonRow add
aria-hidden="true" to their outer wrapper divs, and for SkeletonTitle,
SkeletonButton, SkeletonImage, SkeletonCircle, SkeletonCard, SkeletonIcon add
aria-hidden="true" to the single returned div in each function so all skeleton
elements are ignored by assistive tech.
In `@app/components/Skeleton/Skeleton.module.css`:
- Around line 92-97: Remove the duplicated dark-mode :root variable overrides in
Skeleton.module.css: delete the entire `@media` (prefers-color-scheme: dark) {
:root { --skeleton-base; --skeleton-highlight; --skeleton-card-bg; } } block so
the component relies on the single source of truth in globals.css for those
custom properties (--skeleton-base, --skeleton-highlight, --skeleton-card-bg).
In `@app/components/Skeleton/useSkeletonLoading.js`:
- Around line 5-16: The hook useSkeletonLoading currently always initializes
isLoading to true and relies only on a timer; change it to accept a loading
input (either a boolean or a Promise) and a minDelay (default 1500) so the hook
reflects real fetch state while still enforcing a minimum skeleton display.
Specifically: update useSkeletonLoading to take (loading, minDelay = 1500),
initialize isLoading from the provided loading boolean (or false if no boolean
provided) to avoid server-side skeletons, subscribe to a Promise if loading is a
Promise (set isLoading true until it resolves/rejects), and keep the setTimeout
logic only as a minimum-duration guard that prevents hiding the skeleton before
minDelay has elapsed; reference useSkeletonLoading, isLoading, setIsLoading, and
the internal timer/clearTimeout when implementing these changes.
In `@app/components/TechStack/TechStack.jsx`:
- Around line 4-8: The TechStack component is missing the client directive so
client-only hooks like useSkeletonLoading fail; add the "use client" directive
as the very first line of the TechStack.jsx file so the component (TechStack)
can call the useSkeletonLoading hook safely and remain a client component.
In `@app/loading.js`:
- Around line 3-9: The Loading component's spinner is decorative and the
container doesn't announce loading to assistive tech; update the JSX in Loading
to add aria-hidden="true" to the element with className styles.loadingSpinner,
and add role="status" and aria-live="polite" to the element with className
styles.loadingContainer (ensure the visible span with className
styles.loadingText remains for announcement or include an appropriate
aria-label/message inside the container).
---
Outside diff comments:
In `@app/components/Hero/Hero.jsx`:
- Around line 1-14: The components Hero, About, DownloadApp, Features, and
TechStack call the client hook useSkeletonLoading (which uses
useState/useEffect) but are currently server components; add the React Client
Component directive by inserting "use client" as the very first line of each
file (before any imports) so Hero (component Hero), About, DownloadApp,
Features, and TechStack can legally call useSkeletonLoading.
---
Nitpick comments:
In `@app/components/Features/Features.jsx`:
- Around line 41-53: The featureSkeletons array is being allocated on every
render even when not needed; move its computation inside the isLoading
conditional (or compute lazily) so it’s only created when rendering the loading
state. Update the component to only build featureSkeletons within the block that
checks isLoading (referencing featureSkeletons and isLoading in Features.jsx),
or replace the current top-level map with a small helper function (e.g.,
renderFeatureSkeletons) and call it only when isLoading is true so unnecessary
allocations are avoided.
In `@app/components/Skeleton/index.js`:
- Around line 1-10: Add the hook to the barrel export so consumers can import
everything from the same entry; update the export in index.js to also re-export
useSkeletonLoading (e.g., export { useSkeletonLoading } from
"./useSkeletonLoading" or export { default as useSkeletonLoading } from
"./useSkeletonLoading" depending on the hook's export style) alongside the
existing SkeletonText, SkeletonTitle, etc., ensuring the symbol name
useSkeletonLoading matches the hook file.
In `@app/components/Skeleton/Skeleton.module.css`:
- Around line 31-39: The CSS rules for .skeleton-text (and its :last-child),
.skeleton-avatar, and .skeleton-circle are dead because the components use
inline styles (SkeletonText uses styles.skeleton per line and
SkeletonCircle/SkeletonAvatar set dimensions inline) or the component doesn't
exist; update components to apply these CSS classes instead of duplicating the
styles inline or remove the unused CSS rules. Specifically, in the SkeletonText
component replace per-line inline height/margin/width logic by applying
styles.skeleton-text and relying on .skeleton-text:last-child for the last-item
rule (or remove the CSS and keep inline styles), remove .skeleton-avatar if
there is no SkeletonAvatar component (or add the component and apply
styles.skeleton-avatar), and either have SkeletonCircle apply
styles.skeleton-circle for radius/dimensions or delete the .skeleton-circle rule
so the stylesheet and components remain consistent.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (13)
app/components/About/About.jsxapp/components/DownloadApp/DownloadApp.jsxapp/components/Features/Features.jsxapp/components/Hero/Hero.jsxapp/components/Skeleton/Skeleton.jsxapp/components/Skeleton/Skeleton.module.cssapp/components/Skeleton/index.jsapp/components/Skeleton/useSkeletonLoading.jsapp/components/TechStack/TechStack.jsxapp/globals.cssapp/layout.jsapp/loading.jsapp/loading.module.css
| import { SkeletonCircle, SkeletonTitle, SkeletonText, SkeletonIcon } from "../Skeleton"; | ||
|
|
||
| const About = () => { | ||
| const isLoading = useSkeletonLoading(); |
There was a problem hiding this comment.
Timer-based fake loading unconditionally delays static content
useSkeletonLoading() always starts isLoading = true and flips it after a fixed 1500 ms setTimeout, regardless of whether content is actually being fetched. For these components (About, TechStack, Features, DownloadApp), all content is static — no async data fetch. This means:
- Every page load (and every re-mount on navigation) forces a 1.5s artificial wait, even when content is immediately available — worsening, not improving, perceived performance.
- On hard refresh with SSR, the server renders the loaded state but the client hydrates with
isLoading = true, causing a brief skeleton flash that isn't driven by real resource loading.
If the intent is a "first-impression polish" delay, this trade-off should be documented. If it's meant to cover real async loading, the hook should instead accept a Promise or a data-ready signal.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/About/About.jsx` at line 10, The hook useSkeletonLoading is
forcing a 1.5s artificial loading state for static components (About, TechStack,
Features, DownloadApp) causing unnecessary delay and SSR hydration flashes;
update either the hook or its usages so static pages opt out: change
useSkeletonLoading to accept an optional parameter (e.g., dataReady boolean or
Promise or a duration) and return false immediately when dataReady === true (or
when no duration is provided), or simply remove/replace the hook call in the
listed components with isLoading = false for static content; ensure the symbol
useSkeletonLoading is updated and all references in About, TechStack, Features,
and DownloadApp are modified to pass a readiness flag or bypass the timeout
behavior to avoid client-side skeleton flashes.
| if (isLoading) { | ||
| return ( | ||
| <section className="about"> | ||
| <div className="about-container"> | ||
| <div className="about-logo"> | ||
| <SkeletonCircle size={120} /> | ||
| </div> | ||
| <div className="about-content"> | ||
| <SkeletonTitle style={{ width: "250px" }} /> | ||
| <SkeletonText lines={4} /> | ||
| <div className="social-links"> | ||
| <SkeletonIcon /> | ||
| <SkeletonIcon /> | ||
| <SkeletonIcon /> | ||
| <SkeletonIcon /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| ); |
There was a problem hiding this comment.
Skeleton section is missing ARIA loading state attributes
The PR lists screen-reader friendliness as an explicit goal, but the skeleton <section> has no aria-busy="true", no role="status", and no aria-label. Screen readers will attempt to navigate the empty skeleton <div> elements, providing no useful information.
🛠️ Proposed fix
- <section className="about">
+ <section className="about" aria-busy="true" aria-label="Loading content">📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (isLoading) { | |
| return ( | |
| <section className="about"> | |
| <div className="about-container"> | |
| <div className="about-logo"> | |
| <SkeletonCircle size={120} /> | |
| </div> | |
| <div className="about-content"> | |
| <SkeletonTitle style={{ width: "250px" }} /> | |
| <SkeletonText lines={4} /> | |
| <div className="social-links"> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| ); | |
| if (isLoading) { | |
| return ( | |
| <section className="about" aria-busy="true" aria-label="Loading content"> | |
| <div className="about-container"> | |
| <div className="about-logo"> | |
| <SkeletonCircle size={120} /> | |
| </div> | |
| <div className="about-content"> | |
| <SkeletonTitle style={{ width: "250px" }} /> | |
| <SkeletonText lines={4} /> | |
| <div className="social-links"> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/About/About.jsx` around lines 12 - 31, The skeleton loading
section returned when isLoading is true lacks ARIA loading attributes; update
the JSX in the About component's isLoading branch (the <section
className="about"> returned by the isLoading check) to include aria-busy="true",
role="status", and a descriptive aria-label (e.g., "Loading about content") so
screen-readers know this is a loading placeholder; keep the existing visual
skeleton elements unchanged.
| <div className="social-links"> | ||
| <SkeletonIcon /> | ||
| <SkeletonIcon /> | ||
| <SkeletonIcon /> | ||
| <SkeletonIcon /> | ||
| </div> |
There was a problem hiding this comment.
Skeleton renders 4 icons; actual content has 5 social links
The actual social-links section (lines 52–68) renders 5 anchors (FaEnvelope, SiGitlab, FaGithub, FaDiscord, BsTwitterX), but the skeleton only renders 4 <SkeletonIcon /> elements. This causes a layout shift on transition — the very problem the PR aims to eliminate.
🛠️ Proposed fix
<div className="social-links">
<SkeletonIcon />
<SkeletonIcon />
<SkeletonIcon />
<SkeletonIcon />
+ <SkeletonIcon />
</div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="social-links"> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| </div> | |
| <div className="social-links"> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| <SkeletonIcon /> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/About/About.jsx` around lines 22 - 27, The skeleton for the
social links renders four <SkeletonIcon /> elements but the real social-links
list renders five anchors (FaEnvelope, SiGitlab, FaGithub, FaDiscord,
BsTwitterX), causing a layout shift; update the social-links skeleton in
About.jsx to render five placeholders (either add one more <SkeletonIcon /> or
iterate the same source array used for the real links) so the skeleton structure
matches the actual icons and prevents the transition shift.
| import { useSkeletonLoading } from '../Skeleton/useSkeletonLoading'; | ||
| import { SkeletonTitle, SkeletonButton, SkeletonImage, SkeletonText } from '../Skeleton'; | ||
|
|
||
| const DownloadApp = () => { | ||
| const isLoading = useSkeletonLoading(); |
There was a problem hiding this comment.
"use client" missing — same concern as About.jsx
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/DownloadApp/DownloadApp.jsx` around lines 3 - 7, This file is
missing the React Server Components client directive; add the `"use client"`
pragma as the very first line of DownloadApp.jsx (before any imports) so the
DownloadApp component and its hook useSkeletonLoading run on the client; ensure
the directive is present and mirrors the fix applied in About.jsx to avoid
server/client mismatches for DownloadApp, useSkeletonLoading, SkeletonTitle,
SkeletonButton, SkeletonImage, and SkeletonText.
| import { useSkeletonLoading } from "../Skeleton/useSkeletonLoading"; | ||
| import { SkeletonImage, SkeletonText } from "../Skeleton"; |
There was a problem hiding this comment.
"use client" missing — same concern as About.jsx
Also applies to: 39-39
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/Features/Features.jsx` around lines 6 - 7, This file defines
the Features component and imports client-only hooks/components
(useSkeletonLoading, SkeletonImage, SkeletonText) but is missing the React
Server Components opt-in; add the "use client" directive as the very first line
of Features.jsx (before any imports) so the Features component and its use of
useSkeletonLoading/SkeletonImage/SkeletonText run on the client; also apply the
same "use client" addition to the related component referenced at line 39 that
uses these client-only symbols.
| export function SkeletonText({ lines = 3, className = "", style = {} }) { | ||
| return ( | ||
| <div className={className} style={style}> | ||
| {Array.from({ length: lines }).map((_, i) => ( | ||
| <div | ||
| key={i} | ||
| className={styles.skeleton} | ||
| style={{ | ||
| height: "1em", | ||
| marginBottom: i < lines - 1 ? "0.5em" : "0", | ||
| width: i === lines - 1 ? "70%" : "100%", | ||
| }} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonTitle({ className = "", style = {} }) { | ||
| return ( | ||
| <div | ||
| className={`${styles.skeleton} ${styles.skeletonTitle} ${className}`} | ||
| style={style} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonButton({ className = "", style = {} }) { | ||
| return ( | ||
| <div | ||
| className={`${styles.skeleton} ${styles.skeletonButton} ${className}`} | ||
| style={style} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonImage({ aspectRatio = "4/3", className = "", style = {} }) { | ||
| return ( | ||
| <div | ||
| className={`${styles.skeleton} ${styles.skeletonImage} ${className}`} | ||
| style={{ "--skeleton-aspect-ratio": aspectRatio, ...style }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonCircle({ size = 100, className = "", style = {} }) { | ||
| return ( | ||
| <div | ||
| className={`${styles.skeleton} ${className}`} | ||
| style={{ | ||
| width: size, | ||
| height: size, | ||
| borderRadius: "50%", | ||
| ...style, | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonCard({ className = "", style = {} }) { | ||
| return ( | ||
| <div | ||
| className={`${styles.skeleton} ${styles.skeletonCard} ${className}`} | ||
| style={style} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonRow({ count = 3, className = "", style = {} }) { | ||
| return ( | ||
| <div className={`${styles.skeletonRow} ${className}`} style={style}> | ||
| {Array.from({ length: count }).map((_, i) => ( | ||
| <div key={i} className={styles.skeleton} style={{ height: "80px" }} /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function SkeletonIcon({ className = "", style = {} }) { | ||
| return ( | ||
| <div | ||
| className={`${styles.skeleton} ${styles.skeletonIcon} ${className}`} | ||
| style={style} | ||
| /> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Skeleton placeholder elements are missing aria-hidden="true".
Every component renders empty <div> elements with no semantic meaning. Without aria-hidden="true", screen readers will tab into and announce these as meaningless anonymous regions. The PR objective explicitly requires screen-reader-friendly loaders.
Add aria-hidden="true" to each rendered element (or the outermost wrapper for SkeletonText and SkeletonRow):
♿ Representative diff for all eight components
export function SkeletonText({ lines = 3, className = "", style = {} }) {
return (
- <div className={className} style={style}>
+ <div className={className} style={style} aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => ( export function SkeletonTitle({ className = "", style = {} }) {
return (
<div
className={`${styles.skeleton} ${styles.skeletonTitle} ${className}`}
+ aria-hidden="true"
style={style}
/>Apply the same pattern to SkeletonButton, SkeletonImage, SkeletonCircle, SkeletonCard, SkeletonRow, and SkeletonIcon.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function SkeletonText({ lines = 3, className = "", style = {} }) { | |
| return ( | |
| <div className={className} style={style}> | |
| {Array.from({ length: lines }).map((_, i) => ( | |
| <div | |
| key={i} | |
| className={styles.skeleton} | |
| style={{ | |
| height: "1em", | |
| marginBottom: i < lines - 1 ? "0.5em" : "0", | |
| width: i === lines - 1 ? "70%" : "100%", | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| export function SkeletonTitle({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonTitle} ${className}`} | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonButton({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonButton} ${className}`} | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonImage({ aspectRatio = "4/3", className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonImage} ${className}`} | |
| style={{ "--skeleton-aspect-ratio": aspectRatio, ...style }} | |
| /> | |
| ); | |
| } | |
| export function SkeletonCircle({ size = 100, className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${className}`} | |
| style={{ | |
| width: size, | |
| height: size, | |
| borderRadius: "50%", | |
| ...style, | |
| }} | |
| /> | |
| ); | |
| } | |
| export function SkeletonCard({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonCard} ${className}`} | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonRow({ count = 3, className = "", style = {} }) { | |
| return ( | |
| <div className={`${styles.skeletonRow} ${className}`} style={style}> | |
| {Array.from({ length: count }).map((_, i) => ( | |
| <div key={i} className={styles.skeleton} style={{ height: "80px" }} /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| export function SkeletonIcon({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonIcon} ${className}`} | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonText({ lines = 3, className = "", style = {} }) { | |
| return ( | |
| <div className={className} style={style} aria-hidden="true"> | |
| {Array.from({ length: lines }).map((_, i) => ( | |
| <div | |
| key={i} | |
| className={styles.skeleton} | |
| style={{ | |
| height: "1em", | |
| marginBottom: i < lines - 1 ? "0.5em" : "0", | |
| width: i === lines - 1 ? "70%" : "100%", | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| export function SkeletonTitle({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonTitle} ${className}`} | |
| aria-hidden="true" | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonButton({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonButton} ${className}`} | |
| aria-hidden="true" | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonImage({ aspectRatio = "4/3", className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonImage} ${className}`} | |
| aria-hidden="true" | |
| style={{ "--skeleton-aspect-ratio": aspectRatio, ...style }} | |
| /> | |
| ); | |
| } | |
| export function SkeletonCircle({ size = 100, className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${className}`} | |
| aria-hidden="true" | |
| style={{ | |
| width: size, | |
| height: size, | |
| borderRadius: "50%", | |
| ...style, | |
| }} | |
| /> | |
| ); | |
| } | |
| export function SkeletonCard({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonCard} ${className}`} | |
| aria-hidden="true" | |
| style={style} | |
| /> | |
| ); | |
| } | |
| export function SkeletonRow({ count = 3, className = "", style = {} }) { | |
| return ( | |
| <div className={`${styles.skeletonRow} ${className}`} style={style} aria-hidden="true"> | |
| {Array.from({ length: count }).map((_, i) => ( | |
| <div key={i} className={styles.skeleton} style={{ height: "80px" }} /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| export function SkeletonIcon({ className = "", style = {} }) { | |
| return ( | |
| <div | |
| className={`${styles.skeleton} ${styles.skeletonIcon} ${className}`} | |
| aria-hidden="true" | |
| style={style} | |
| /> | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/Skeleton/Skeleton.jsx` around lines 3 - 88, The rendered
skeleton divs are missing aria-hidden="true", causing screen readers to announce
meaningless regions; update each component to add aria-hidden="true" on the root
element: for SkeletonText and SkeletonRow add aria-hidden="true" to their outer
wrapper divs, and for SkeletonTitle, SkeletonButton, SkeletonImage,
SkeletonCircle, SkeletonCard, SkeletonIcon add aria-hidden="true" to the single
returned div in each function so all skeleton elements are ignored by assistive
tech.
| @media (prefers-color-scheme: dark) { | ||
| :root { | ||
| --skeleton-base: #2a2a2a; | ||
| --skeleton-highlight: #3d3d3d; | ||
| --skeleton-card-bg: #1f1f1f; | ||
| } |
There was a problem hiding this comment.
Remove duplicate dark-mode :root overrides — already defined in globals.css.
These three custom properties are byte-for-byte identical to what's already in app/globals.css (lines 12-17). Because CSS custom properties on :root are always global — even inside a CSS module — this block is redundant and creates a two-source-of-truth problem: the values can drift when one file is updated without the other.
🗑️ Proposed removal
-.skeleton-icon { ... }
-
-@media (prefers-color-scheme: dark) {
- :root {
- --skeleton-base: `#2a2a2a`;
- --skeleton-highlight: `#3d3d3d`;
- --skeleton-card-bg: `#1f1f1f`;
- }
-}
+.skeleton-icon { ... }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/Skeleton/Skeleton.module.css` around lines 92 - 97, Remove the
duplicated dark-mode :root variable overrides in Skeleton.module.css: delete the
entire `@media` (prefers-color-scheme: dark) { :root { --skeleton-base;
--skeleton-highlight; --skeleton-card-bg; } } block so the component relies on
the single source of truth in globals.css for those custom properties
(--skeleton-base, --skeleton-highlight, --skeleton-card-bg).
| export function useSkeletonLoading(delay = 1500) { | ||
| const [isLoading, setIsLoading] = useState(true); | ||
|
|
||
| useEffect(() => { | ||
| const timer = setTimeout(() => { | ||
| setIsLoading(false); | ||
| }, delay); | ||
|
|
||
| return () => clearTimeout(timer); | ||
| }, [delay]); | ||
|
|
||
| return isLoading; |
There was a problem hiding this comment.
Time-based simulation decoupled from actual data loading.
isLoading is driven solely by a wall-clock timer, not by any real async operation. This introduces two compounding problems:
- False timing: Content is hidden for a fixed 1500 ms even when it is already available, creating an artificial delay; conversely, if any data actually takes longer than 1500 ms to arrive, the skeleton dismisses before content is ready.
- SEO regression: In Next.js App Router, client components are pre-rendered on the server and hydrated on the client. Because
useState(true)is the initial state, the server-rendered HTML for every consumer (Hero, Features, TechStack, etc.) will contain only empty skeleton<div>elements instead of real content — stripping the page of indexable text.
The hook should instead accept a Promise or a boolean loading prop that reflects actual data-fetching state, falling back to the timer only as a UX minimum:
-export function useSkeletonLoading(delay = 1500) {
- const [isLoading, setIsLoading] = useState(true);
-
- useEffect(() => {
- const timer = setTimeout(() => {
- setIsLoading(false);
- }, delay);
-
- return () => clearTimeout(timer);
- }, [delay]);
-
- return isLoading;
-}
+export function useSkeletonLoading(isDataLoading = false, minDelay = 0) {
+ const [timerDone, setTimerDone] = useState(minDelay === 0);
+
+ useEffect(() => {
+ if (minDelay === 0) return;
+ const timer = setTimeout(() => setTimerDone(true), minDelay);
+ return () => clearTimeout(timer);
+ }, [minDelay]);
+
+ return isDataLoading || !timerDone;
+}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/Skeleton/useSkeletonLoading.js` around lines 5 - 16, The hook
useSkeletonLoading currently always initializes isLoading to true and relies
only on a timer; change it to accept a loading input (either a boolean or a
Promise) and a minDelay (default 1500) so the hook reflects real fetch state
while still enforcing a minimum skeleton display. Specifically: update
useSkeletonLoading to take (loading, minDelay = 1500), initialize isLoading from
the provided loading boolean (or false if no boolean provided) to avoid
server-side skeletons, subscribe to a Promise if loading is a Promise (set
isLoading true until it resolves/rejects), and keep the setTimeout logic only as
a minimum-duration guard that prevents hiding the skeleton before minDelay has
elapsed; reference useSkeletonLoading, isLoading, setIsLoading, and the internal
timer/clearTimeout when implementing these changes.
| import { useSkeletonLoading } from "../Skeleton/useSkeletonLoading"; | ||
| import { SkeletonTitle, SkeletonImage } from "../Skeleton"; | ||
|
|
||
| const TechStack = () => { | ||
| const isLoading = useSkeletonLoading(); |
There was a problem hiding this comment.
"use client" missing — same concern as About.jsx
useSkeletonLoading uses client-only hooks; this component needs "use client" for the same reason detailed in About.jsx.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/TechStack/TechStack.jsx` around lines 4 - 8, The TechStack
component is missing the client directive so client-only hooks like
useSkeletonLoading fail; add the "use client" directive as the very first line
of the TechStack.jsx file so the component (TechStack) can call the
useSkeletonLoading hook safely and remain a client component.
| export default function Loading() { | ||
| return ( | ||
| <div className={styles.loadingContainer}> | ||
| <div className={styles.loadingSpinner}></div> | ||
| <span className={styles.loadingText}>Loading content...</span> | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
Missing ARIA attributes on loading UI.
The spinner div is purely decorative but has no aria-hidden="true", and the container lacks role="status" or aria-live="polite", so screen readers will neither announce the loading state properly nor suppress the meaningless spinner element.
♿ Proposed accessibility fix
export default function Loading() {
return (
- <div className={styles.loadingContainer}>
- <div className={styles.loadingSpinner}></div>
- <span className={styles.loadingText}>Loading content...</span>
+ <div className={styles.loadingContainer} role="status" aria-live="polite">
+ <div className={styles.loadingSpinner} aria-hidden="true"></div>
+ <span className={styles.loadingText}>Loading content...</span>
</div>
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export default function Loading() { | |
| return ( | |
| <div className={styles.loadingContainer}> | |
| <div className={styles.loadingSpinner}></div> | |
| <span className={styles.loadingText}>Loading content...</span> | |
| </div> | |
| ); | |
| export default function Loading() { | |
| return ( | |
| <div className={styles.loadingContainer} role="status" aria-live="polite"> | |
| <div className={styles.loadingSpinner} aria-hidden="true"></div> | |
| <span className={styles.loadingText}>Loading content...</span> | |
| </div> | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/loading.js` around lines 3 - 9, The Loading component's spinner is
decorative and the container doesn't announce loading to assistive tech; update
the JSX in Loading to add aria-hidden="true" to the element with className
styles.loadingSpinner, and add role="status" and aria-live="polite" to the
element with className styles.loadingContainer (ensure the visible span with
className styles.loadingText remains for announcement or include an appropriate
aria-label/message inside the container).
Description
This PR introduces a consistent and reusable skeleton loading system to improve perceived performance and eliminate layout shifts across the application.
Previously, certain sections displayed blank regions or experienced layout jumps while content, images, or dynamic data were loading. This created a perception of slow or unresponsive behavior, especially on low-bandwidth connections.
This update implements structured skeleton placeholders and lightweight shimmer animations to provide immediate visual feedback and maintain layout stability during loading states.
Fixes #277
Screen.Recording.2026-02-23.234237.mp4
What Was Implemented
app/loading.jsprefers-color-scheme)Type of change
How Has This Been Tested?
npm run devlocally.npm run buildpasses without warnings.The skeletons correctly render before content loads and are replaced seamlessly once content becomes available.
Checklist:
Summary by CodeRabbit
Release Notes
New Features
Style