diff --git a/AGENTS.md b/AGENTS.md
index 1cf023889c..da707b6033 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,6 +35,7 @@ Use these skills for detailed patterns on-demand:
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
+| `prowler-ui-skeletons` | shadcn skeleton loading and content reveal conventions | [SKILL.md](skills/prowler-ui-skeletons/SKILL.md) |
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
@@ -85,6 +86,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating new skills | `skill-creator` |
| Creating or reviewing Django migrations | `django-migration-psql` |
| Creating/modifying Prowler UI components | `prowler-ui` |
+| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
| Creating/modifying models, views, serializers | `prowler-api` |
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
diff --git a/skills/prowler-ui-skeletons/SKILL.md b/skills/prowler-ui-skeletons/SKILL.md
new file mode 100644
index 0000000000..071af8c00b
--- /dev/null
+++ b/skills/prowler-ui-skeletons/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: prowler-ui-skeletons
+description: "Trigger: skeleton, loading state, Suspense fallback, content reveal, shimmer. Use Prowler shadcn skeletons correctly."
+license: Apache-2.0
+metadata:
+ author: prowler-cloud
+ version: "1.0"
+ scope: [root, ui]
+ auto_invoke:
+ - "Creating/modifying skeletons"
+ - "Creating/modifying loading states"
+ - "Adding Suspense fallbacks"
+---
+
+## Activation Contract
+
+Use this skill before creating or modifying any Prowler UI skeleton, loading placeholder, Suspense fallback, or loading-to-content transition.
+
+## Hard Rules
+
+- Prefer shadcn `Skeleton` from `@/components/shadcn`; do not add new HeroUI skeletons.
+- Do not mix HeroUI and shadcn inside the same new loading surface.
+- Keep scanner/shimmer behavior centralized in shadcn `Skeleton`; never duplicate scanner CSS in feature files.
+- For Suspense data loading, wrap the boundary with `SkeletonBoundary` so fallback removal and real content reveal are paired.
+- For client-state loading (`isLoading`, drawers, modals, expanded rows), add a reveal wrapper around the resolved content, not around the skeleton.
+- Respect `motion-reduce`; every animation must degrade to no transform/transition.
+- Preserve layout stability: skeleton dimensions must match the final content as closely as practical.
+- Do not migrate legacy/HeroUI skeletons unless the task explicitly includes that migration.
+
+## Decision Gates
+
+| Situation | Action |
+| --- | --- |
+| Page/server data with `Suspense` fallback | Use `SkeletonBoundary` with the skeleton fallback. |
+| Nested Suspense inside tab/chart content | Use `SkeletonBoundary` unless the fallback is legacy/HeroUI. |
+| Client state swaps skeleton to content | Keep shadcn `Skeleton`; wrap resolved content with `SkeletonContentReveal` or an equivalent shared reveal. |
+| Existing HeroUI skeleton | Leave unchanged unless migration is explicitly requested. |
+| Text-only `Loading...` fallback | Replace only if the requested scope includes that surface. |
+
+## Execution Steps
+
+1. Identify whether the skeleton is shadcn, HeroUI legacy, or text-only fallback.
+2. If shadcn + Suspense, use `SkeletonBoundary` instead of raw `Suspense`.
+3. If shadcn + client state, keep the skeleton fallback and reveal only the loaded content.
+4. Verify reduced-motion classes remain present.
+5. Add or update focused tests when changing shared skeleton primitives or reusable boundaries.
+
+## Output Contract
+
+Report:
+- Which loading surfaces changed.
+- Whether each surface is Suspense-boundary or client-state loading.
+- Which legacy/HeroUI skeletons were intentionally left untouched.
+- Test/typecheck evidence when implementation changes are made.
+
+## References
+
+- `ui/components/shadcn/skeleton/skeleton.tsx`
+- `ui/components/shadcn/skeleton/skeleton-boundary.tsx`
+- `ui/components/shadcn/skeleton/skeleton-content-reveal.tsx`
diff --git a/ui/AGENTS.md b/ui/AGENTS.md
index 38af45860f..03b6897701 100644
--- a/ui/AGENTS.md
+++ b/ui/AGENTS.md
@@ -3,6 +3,7 @@
> **Skills Reference**: For detailed patterns, use these skills:
>
> - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns
+> - [`prowler-ui-skeletons`](../skills/prowler-ui-skeletons/SKILL.md) - shadcn skeleton loading and content reveal conventions
> - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive)
> - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces
> - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler
@@ -19,35 +20,36 @@
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
-| Action | Skill |
-| -------------------------------------------------------------- | ------------------- |
-| Add changelog entry for a PR or feature | `prowler-changelog` |
-| App Router / Server Actions | `nextjs-16` |
-| Building AI chat features | `ai-sdk-5` |
-| Committing changes | `prowler-commit` |
-| Create PR that requires changelog entry | `prowler-changelog` |
-| Creating Zod schemas | `zod-4` |
-| Creating a git commit | `prowler-commit` |
-| Creating/modifying Prowler UI components | `prowler-ui` |
-| Fixing bug | `tdd` |
-| Implementing feature | `tdd` |
-| Modifying component | `tdd` |
-| Refactoring code | `tdd` |
-| Review changelog format and conventions | `prowler-changelog` |
-| Testing hooks or utilities | `vitest` |
-| Update CHANGELOG.md in any component | `prowler-changelog` |
-| Using Zustand stores | `zustand-5` |
-| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
-| Working on task | `tdd` |
-| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
-| Working with Tailwind classes | `tailwind-4` |
-| Writing Playwright E2E tests | `playwright` |
-| Writing Prowler UI E2E tests | `prowler-test-ui` |
-| Writing React component tests | `vitest` |
-| Writing React components | `react-19` |
-| Writing TypeScript types/interfaces | `typescript` |
-| Writing Vitest tests | `vitest` |
-| Writing unit tests for UI | `vitest` |
+| Action | Skill |
+| ------------------------------------------------------------------- | ---------------------- |
+| Add changelog entry for a PR or feature | `prowler-changelog` |
+| App Router / Server Actions | `nextjs-16` |
+| Building AI chat features | `ai-sdk-5` |
+| Committing changes | `prowler-commit` |
+| Create PR that requires changelog entry | `prowler-changelog` |
+| Creating Zod schemas | `zod-4` |
+| Creating a git commit | `prowler-commit` |
+| Creating/modifying Prowler UI components | `prowler-ui` |
+| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
+| Fixing bug | `tdd` |
+| Implementing feature | `tdd` |
+| Modifying component | `tdd` |
+| Refactoring code | `tdd` |
+| Review changelog format and conventions | `prowler-changelog` |
+| Testing hooks or utilities | `vitest` |
+| Update CHANGELOG.md in any component | `prowler-changelog` |
+| Using Zustand stores | `zustand-5` |
+| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
+| Working on task | `tdd` |
+| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
+| Working with Tailwind classes | `tailwind-4` |
+| Writing Playwright E2E tests | `playwright` |
+| Writing Prowler UI E2E tests | `prowler-test-ui` |
+| Writing React component tests | `vitest` |
+| Writing React components | `react-19` |
+| Writing TypeScript types/interfaces | `typescript` |
+| Writing Vitest tests | `vitest` |
+| Writing unit tests for UI | `vitest` |
---
diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx
index a67509bed1..eedd7d7253 100644
--- a/ui/app/(prowler)/compliance/page.tsx
+++ b/ui/app/(prowler)/compliance/page.tsx
@@ -1,5 +1,4 @@
import { Info } from "lucide-react";
-import { Suspense } from "react";
import {
getComplianceOverviewMetadataInfo,
@@ -16,6 +15,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
+import { SkeletonBoundary } from "@/components/shadcn/skeleton/skeleton-boundary";
import { ContentLayout } from "@/components/ui";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import {
@@ -156,7 +156,7 @@ export default async function Compliance({
)}
{/* Row 3: Compliance grid with client-side search */}
-
@@ -169,7 +169,7 @@ export default async function Compliance({
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
-
+
>
) : (
diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx
index 7ff9780292..64b313542b 100644
--- a/ui/app/(prowler)/findings/page.tsx
+++ b/ui/app/(prowler)/findings/page.tsx
@@ -1,5 +1,3 @@
-import { Suspense } from "react";
-
import {
adaptFindingGroupsResponse,
getFindingGroups,
@@ -14,6 +12,7 @@ import {
FindingsGroupTable,
SkeletonTableFindings,
} from "@/components/findings/table";
+import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import {
@@ -111,12 +110,12 @@ export default async function Findings({
}
/>
- }>
+ }>
-
+
);
diff --git a/ui/app/(prowler)/invitations/page.tsx b/ui/app/(prowler)/invitations/page.tsx
index 57cc268354..48980aceb4 100644
--- a/ui/app/(prowler)/invitations/page.tsx
+++ b/ui/app/(prowler)/invitations/page.tsx
@@ -1,5 +1,4 @@
import Link from "next/link";
-import { Suspense } from "react";
import { getInvitations } from "@/actions/invitations/invitation";
import { getRoles } from "@/actions/roles";
@@ -10,7 +9,7 @@ import {
ColumnsInvitation,
SkeletonTableInvitation,
} from "@/components/invitations/table";
-import { Button } from "@/components/shadcn";
+import { Button, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { InvitationProps, Role, SearchParamsProps } from "@/types";
@@ -39,9 +38,12 @@ export default async function Invitations({
- }>
+ }
+ >
-
+
);
diff --git a/ui/app/(prowler)/mutelist/page.tsx b/ui/app/(prowler)/mutelist/page.tsx
index 97fd2733c2..f479fbb2f7 100644
--- a/ui/app/(prowler)/mutelist/page.tsx
+++ b/ui/app/(prowler)/mutelist/page.tsx
@@ -1,5 +1,4 @@
-import { Suspense } from "react";
-
+import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types/components";
@@ -18,9 +17,12 @@ export default async function MutelistPage({
}>
+ }
+ >
-
+
}
/>
diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx
index 99f152b4e4..e3738f360a 100644
--- a/ui/app/(prowler)/page.tsx
+++ b/ui/app/(prowler)/page.tsx
@@ -1,7 +1,6 @@
-import { Suspense } from "react";
-
import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
+import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
@@ -47,55 +46,64 @@ export default async function Home({
- }>
+ }
+ className="w-full lg:max-w-[312px]"
+ >
-
+
- }>
+ }
+ className="min-w-[312px] flex-1 md:min-w-[380px]"
+ >
-
+
- }>
+ }
+ className="min-w-[312px] flex-1 md:min-w-[380px]"
+ >
-
+
- }>
+ }>
-
+
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
- }>
+ }>
-
+
- }>
+ }>
-
+
{/* Charts column: Attack Surface on top, Findings Over Time below */}
-
}>
+
}>
-
-
}>
+
+
}>
-
+
- }>
+ }>
-
+
);
diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx
index cb2dae2e8b..43aec0f6db 100644
--- a/ui/app/(prowler)/providers/page.tsx
+++ b/ui/app/(prowler)/providers/page.tsx
@@ -1,8 +1,6 @@
-import { Suspense } from "react";
-
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
-import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
+import { Skeleton, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import { SearchParamsProps } from "@/types";
@@ -30,20 +28,20 @@ export default async function Providers({
}
>
-
+
}
providerGroupsContent={
- }
>
-
+
}
/>
diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx
index fb7e5ab63d..feed0a011d 100644
--- a/ui/app/(prowler)/resources/page.tsx
+++ b/ui/app/(prowler)/resources/page.tsx
@@ -1,5 +1,3 @@
-import { Suspense } from "react";
-
import { getAllProviders } from "@/actions/providers";
import {
getLatestMetadataInfo,
@@ -11,6 +9,7 @@ import {
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ResourcesTableWithSelection } from "@/components/resources/table";
+import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import {
@@ -86,12 +85,12 @@ export default async function Resources({
uniqueGroups={uniqueGroups}
/>
- }>
+ }>
-
+
);
diff --git a/ui/app/(prowler)/roles/page.tsx b/ui/app/(prowler)/roles/page.tsx
index 259776c5a3..a59338dd6c 100644
--- a/ui/app/(prowler)/roles/page.tsx
+++ b/ui/app/(prowler)/roles/page.tsx
@@ -1,12 +1,11 @@
import Link from "next/link";
-import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters";
import { filterRoles } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table";
-import { Button } from "@/components/shadcn";
+import { Button, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { SearchParamsProps } from "@/types";
@@ -34,9 +33,12 @@ export default async function Roles({
- }>
+ }
+ >
-
+
);
diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx
index 62aba551ae..742d536052 100644
--- a/ui/app/(prowler)/scans/page.tsx
+++ b/ui/app/(prowler)/scans/page.tsx
@@ -1,5 +1,3 @@
-import { Suspense } from "react";
-
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { auth } from "@/auth.config";
@@ -12,6 +10,7 @@ import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
+import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import {
ProviderProps,
@@ -88,7 +87,7 @@ export default async function Scans({
hasManageScansPermission={hasManageScansPermission}
activeScanCount={activeScanCount}
>
-
-
+
)}
diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx
index 4b26c0baf2..860db010bf 100644
--- a/ui/app/(prowler)/users/page.tsx
+++ b/ui/app/(prowler)/users/page.tsx
@@ -1,12 +1,11 @@
import Link from "next/link";
-import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles";
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
import { auth } from "@/auth.config";
import { FilterControls } from "@/components/filters";
import { AddIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
+import { Button, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
@@ -35,9 +34,12 @@ export default async function Users({
- }>
+ }
+ >
-
+
);
diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx
index f200fda7e2..35eff00857 100644
--- a/ui/components/shadcn/badge/badge.tsx
+++ b/ui/components/shadcn/badge/badge.tsx
@@ -5,7 +5,7 @@ import { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[background-color,border-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none overflow-hidden",
{
variants: {
variant: {
diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts
index 91c08ef72e..b9743d92cd 100644
--- a/ui/components/shadcn/index.ts
+++ b/ui/components/shadcn/index.ts
@@ -20,6 +20,8 @@ export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";
export * from "./skeleton/skeleton";
+export * from "./skeleton/skeleton-boundary";
+export * from "./skeleton/skeleton-content-reveal";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./textarea/textarea";
diff --git a/ui/components/shadcn/progress.test.tsx b/ui/components/shadcn/progress.test.tsx
new file mode 100644
index 0000000000..5f0bb64874
--- /dev/null
+++ b/ui/components/shadcn/progress.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Progress } from "./progress";
+
+describe("Progress", () => {
+ it("animates progress value changes with a transform-only transition", () => {
+ // Given
+ render();
+
+ // When
+ const root = screen.getByRole("progressbar", { name: /scan progress/i });
+ const indicator = root.querySelector("[data-slot='progress-indicator']");
+
+ // Then
+ expect(root).toHaveClass(
+ "transition-colors",
+ "duration-200",
+ "ease-out",
+ "motion-reduce:transition-none",
+ );
+ expect(indicator).toHaveClass(
+ "transition-transform",
+ "duration-300",
+ "ease-out",
+ "motion-reduce:transition-none",
+ );
+ expect(indicator).toHaveStyle({ transform: "translateX(-60%)" });
+ });
+});
diff --git a/ui/components/shadcn/progress.tsx b/ui/components/shadcn/progress.tsx
index 3a549f4004..c92d456c1d 100644
--- a/ui/components/shadcn/progress.tsx
+++ b/ui/components/shadcn/progress.tsx
@@ -22,7 +22,7 @@ function Progress({
data-slot="progress"
value={normalizedValue}
className={cn(
- "border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border",
+ "border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border transition-colors duration-200 ease-out motion-reduce:transition-none",
className,
)}
{...props}
@@ -30,7 +30,7 @@ function Progress({
{
+ it("wraps resolved content with the shared skeleton reveal", () => {
+ // Given
+ render(
+ }>
+
+ ,
+ );
+
+ // When
+ const reveal = screen.getByTestId("skeleton-content-reveal");
+
+ // Then
+ expect(screen.getByLabelText("Resolved content")).toBeInTheDocument();
+ expect(reveal).toHaveAttribute("data-motion", "skeleton-content-handoff");
+ });
+
+ it("forwards className to the reveal wrapper", () => {
+ // Given
+ render(
+ }
+ className="custom-boundary"
+ >
+ Ready
+ ,
+ );
+
+ // When
+ const reveal = screen.getByTestId("skeleton-content-reveal");
+
+ // Then
+ expect(reveal).toHaveClass("custom-boundary");
+ });
+});
diff --git a/ui/components/shadcn/skeleton/skeleton-boundary.tsx b/ui/components/shadcn/skeleton/skeleton-boundary.tsx
new file mode 100644
index 0000000000..8950ad5e13
--- /dev/null
+++ b/ui/components/shadcn/skeleton/skeleton-boundary.tsx
@@ -0,0 +1,25 @@
+import { ReactNode, Suspense } from "react";
+
+import { SkeletonContentReveal } from "./skeleton-content-reveal";
+
+interface SkeletonBoundaryProps {
+ children: ReactNode;
+ fallback: ReactNode;
+ className?: string;
+}
+
+function SkeletonBoundary({
+ children,
+ fallback,
+ className,
+}: SkeletonBoundaryProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export { SkeletonBoundary };
diff --git a/ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx b/ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx
new file mode 100644
index 0000000000..d0722f4859
--- /dev/null
+++ b/ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { SkeletonContentReveal } from "./skeleton-content-reveal";
+
+describe("SkeletonContentReveal", () => {
+ it("reveals streamed content with insertion-time CSS motion", () => {
+ // Given
+ render(
+
+
+ ,
+ );
+
+ // When
+ const wrapper = screen.getByTestId("skeleton-content-reveal");
+
+ // Then
+ expect(screen.getByLabelText("Loaded content")).toBeInTheDocument();
+ expect(wrapper).toHaveAttribute("data-motion", "skeleton-content-handoff");
+ expect(wrapper).toHaveClass(
+ "transition-[opacity,transform]",
+ "duration-700",
+ "starting:opacity-0",
+ "starting:translate-y-3",
+ "opacity-100",
+ "translate-y-0",
+ "motion-reduce:transform-none",
+ "motion-reduce:transition-none",
+ );
+ });
+
+ it("merges caller classes without dropping the motion contract", () => {
+ // Given
+ render(
+
+ Ready
+ ,
+ );
+
+ // When
+ const wrapper = screen.getByTestId("skeleton-content-reveal");
+
+ // Then
+ expect(wrapper).toHaveClass(
+ "custom-reveal",
+ "transition-[opacity,transform]",
+ "starting:opacity-0",
+ );
+ });
+});
diff --git a/ui/components/shadcn/skeleton/skeleton-content-reveal.tsx b/ui/components/shadcn/skeleton/skeleton-content-reveal.tsx
new file mode 100644
index 0000000000..2f49efadd1
--- /dev/null
+++ b/ui/components/shadcn/skeleton/skeleton-content-reveal.tsx
@@ -0,0 +1,28 @@
+import { ReactNode } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface SkeletonContentRevealProps {
+ children: ReactNode;
+ className?: string;
+}
+
+function SkeletonContentReveal({
+ children,
+ className,
+}: SkeletonContentRevealProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { SkeletonContentReveal };
diff --git a/ui/components/shadcn/skeleton/skeleton.test.tsx b/ui/components/shadcn/skeleton/skeleton.test.tsx
new file mode 100644
index 0000000000..fc86daafc2
--- /dev/null
+++ b/ui/components/shadcn/skeleton/skeleton.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Skeleton } from "./skeleton";
+
+describe("Skeleton", () => {
+ it("uses a subtle scanner animation that respects reduced motion", () => {
+ // Given
+ render();
+
+ // When
+ const skeleton = screen.getByLabelText("Loading providers");
+
+ // Then
+ expect(skeleton).toHaveClass(
+ "relative",
+ "overflow-hidden",
+ "bg-border-neutral-tertiary",
+ "transition-colors",
+ "duration-500",
+ "ease-out",
+ );
+
+ const scanner = skeleton.querySelector("[data-slot='skeleton-scanner']");
+ expect(scanner).toHaveClass(
+ "animate-skeleton-scan",
+ "bg-gradient-to-r",
+ "from-transparent",
+ "via-white/10",
+ "to-transparent",
+ "motion-reduce:hidden",
+ );
+ });
+});
diff --git a/ui/components/shadcn/skeleton/skeleton.tsx b/ui/components/shadcn/skeleton/skeleton.tsx
index 34971c0554..e535d552c0 100644
--- a/ui/components/shadcn/skeleton/skeleton.tsx
+++ b/ui/components/shadcn/skeleton/skeleton.tsx
@@ -1,15 +1,25 @@
import { cn } from "@/lib/utils";
-function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+function Skeleton({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
return (
+ >
+
+ {children}
+
);
}
diff --git a/ui/components/shadcn/spinner/loading-state.test.tsx b/ui/components/shadcn/spinner/loading-state.test.tsx
index c2cddc59e7..ba1494297b 100644
--- a/ui/components/shadcn/spinner/loading-state.test.tsx
+++ b/ui/components/shadcn/spinner/loading-state.test.tsx
@@ -33,4 +33,22 @@ describe("LoadingState", () => {
const { container } = render();
expect(container.firstChild).toHaveClass("custom-wrapper");
});
+
+ it("animates the loading state entry and label color subtly", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass(
+ "animate-in",
+ "fade-in-0",
+ "duration-200",
+ "ease-out",
+ "motion-reduce:animate-none",
+ "motion-reduce:transition-none",
+ );
+ expect(screen.getByText("Loading findings...")).toHaveClass(
+ "transition-colors",
+ "duration-200",
+ "ease-out",
+ "motion-reduce:transition-none",
+ );
+ });
});
diff --git a/ui/components/shadcn/spinner/loading-state.tsx b/ui/components/shadcn/spinner/loading-state.tsx
index 77355c840f..b4b9d72a37 100644
--- a/ui/components/shadcn/spinner/loading-state.tsx
+++ b/ui/components/shadcn/spinner/loading-state.tsx
@@ -17,11 +17,16 @@ export function LoadingState({
}: LoadingStateProps) {
return (
{label && (
- {label}
+
+ {label}
+
)}
);
diff --git a/ui/components/shadcn/spinner/spinner.tsx b/ui/components/shadcn/spinner/spinner.tsx
index 7ded4c58c7..3ff7654345 100644
--- a/ui/components/shadcn/spinner/spinner.tsx
+++ b/ui/components/shadcn/spinner/spinner.tsx
@@ -19,7 +19,10 @@ interface SpinnerProps {
export function Spinner({ className }: SpinnerProps) {
return (