diff --git a/src/components/index.ts b/src/components/index.ts
index 00af82e99..02784bf5c 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -34,6 +34,7 @@ export * from './layout';
export { DirectImportGlobalNavigation as GlobalNavigation } from './layout/global-navigation';
export * from './legacy-banner';
export * from './links';
+export * from './loading-skeleton';
export * from './logos';
export * from './margin-indicator';
export * from './messages';
diff --git a/src/components/loading-skeleton/LoadingSkeleton.tsx b/src/components/loading-skeleton/LoadingSkeleton.tsx
new file mode 100644
index 000000000..d821c74ca
--- /dev/null
+++ b/src/components/loading-skeleton/LoadingSkeleton.tsx
@@ -0,0 +1,127 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { ReactNode } from 'react';
+import {
+ StyledLoadingSkeletonText,
+ StyledLoadingSkeletonTextLastParagraphLine,
+ StyledLoadingSkeletonWrapper,
+ StyledLoadingSkeletonWrapperDisk,
+ StyledParagraphWrapper,
+} from './LoadingSkeletonStyles';
+import { LoadingSkeletonVariety } from './LoadingSkeletonTypes';
+
+export interface LoadingSkeletonProps {
+ className?: string;
+ /**
+ * Wrapped components will be hidden when isLoading is true. If variety is not Text or Paragraph, the LoadingSkeleton will match the size of its child.
+ */
+ children?: ReactNode;
+ /**
+ * Displays the skeleton if `true`.
+ * Displays its children if `false`.
+ * @default true
+ */
+ isLoading?: boolean;
+ /**
+ * The type of skeleton. The varieties fall in two categories:
+ * - text: Text or Paragraph, meant as a placeholder for text (either a label/heading or a paragraph of text). They are auto-sized according to the font size and line-height.
+ * - shapes: Rectangle or Disk. These are meant to wrap components and are auto-sized to their child's dimensions.
+ */
+ variety: `${LoadingSkeletonVariety}`;
+}
+
+/**
+ * Placeholder to display while we wait for data to be available.
+ * This can be used in 3 ways:
+ *
+ * 1) For text, place the LoadingSkeleton inside the text component (i.e. Text, Heading, ...), but around the contents.
+ * It will inherit the size of the font. This works for variety 'text' & 'paragraph'.
+ * ```
+ *
+ *
+ * {stringDataThatNeedsLoading}
+ *
+ *
+ * ```
+ *
+ * 2) Wrap components to have the LoadingSkeleton inherit its size (if already known).
+ * Works perfectly for fixed-size components whose state depends on async data.
+ *
+ * ```
+ *
+ *
+ *
+ * ```
+ *
+ * 3) You can build a loading screen or component with standalone LoadingSkeletons
+ * ```
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * ⚠️ Accessibility ⚠️
+ *
+ * LoadingSkeletons are `aria-hidden`. A11y must be handled outside of this component, typically by using a LoadingContainer.
+ * Check out LoadingContainer's tsdoc for more information.
+ */
+export function LoadingSkeleton(props: Readonly) {
+ const { children, isLoading = true, variety, ...radixProps } = props;
+
+ if (!isLoading) {
+ return children;
+ }
+
+ const commonProps = { ...radixProps, 'aria-hidden': true };
+
+ switch (variety) {
+ // Text v
+ case LoadingSkeletonVariety.Text:
+ return ;
+ case LoadingSkeletonVariety.Paragraph:
+ return (
+
+
+
+
+
+ );
+
+ case LoadingSkeletonVariety.Disk:
+ return (
+
+ {children}
+
+ );
+ case LoadingSkeletonVariety.Rectangle:
+ default:
+ return (
+ {children}
+ );
+ }
+}
+
+LoadingSkeleton.displayName = 'LoadingSkeleton';
diff --git a/src/components/loading-skeleton/LoadingSkeletonStyles.tsx b/src/components/loading-skeleton/LoadingSkeletonStyles.tsx
new file mode 100644
index 000000000..7e8d5cace
--- /dev/null
+++ b/src/components/loading-skeleton/LoadingSkeletonStyles.tsx
@@ -0,0 +1,102 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { keyframes } from '@emotion/react';
+import styled from '@emotion/styled';
+import { cssVar } from '~utils/design-tokens';
+import { Label } from '../typography';
+import { StyledHeading } from '../typography/Heading';
+
+export const DEFAULT_LOADING_SKELETON_WIDTH_LABEL = '96px';
+export const DEFAULT_LOADING_SKELETON_WIDTH_HEADING = '176px';
+export const LAST_PARAGRAPH_LINE_WIDTH = '40%';
+
+const shimmer = keyframes`
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+`;
+
+const LoadingSkeletonBaseStyle = styled.div`
+ background-color: ${cssVar('color-surface-hover')};
+
+ border-radius: ${cssVar('border-radius-200')};
+
+ position: relative; // necessary so the ::after pseudoelement doesn't misbehave with the container
+ overflow: hidden;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ ${cssVar('color-surface-default')} 50%,
+ transparent 100%
+ );
+ animation: ${shimmer} 2s infinite;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+ }
+`;
+
+export const StyledLoadingSkeletonText = styled(LoadingSkeletonBaseStyle)`
+ height: 1em;
+ margin-block: calc((1lh - 1em) / 2); // set margins to match the line-height
+ min-width: ${DEFAULT_LOADING_SKELETON_WIDTH_LABEL};
+ max-width: ${cssVar('sizes-typography-max-width-default')};
+
+ ${Label} & {
+ width: ${DEFAULT_LOADING_SKELETON_WIDTH_LABEL};
+ }
+
+ ${StyledHeading} & {
+ width: ${DEFAULT_LOADING_SKELETON_WIDTH_HEADING};
+ }
+`;
+
+export const StyledLoadingSkeletonTextLastParagraphLine = styled(StyledLoadingSkeletonText)`
+ width: ${LAST_PARAGRAPH_LINE_WIDTH};
+`;
+
+export const StyledLoadingSkeletonWrapper = styled(LoadingSkeletonBaseStyle)`
+ width: fit-content;
+ height: fit-content;
+
+ // Hide the child
+ & > * {
+ visibility: hidden;
+ }
+`;
+
+export const StyledLoadingSkeletonWrapperDisk = styled(StyledLoadingSkeletonWrapper)`
+ border-radius: ${cssVar('border-radius-full')};
+`;
+
+export const StyledParagraphWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${cssVar('dimension-space-75')};
+`;
diff --git a/src/components/loading-skeleton/LoadingSkeletonTypes.tsx b/src/components/loading-skeleton/LoadingSkeletonTypes.tsx
new file mode 100644
index 000000000..ca2244865
--- /dev/null
+++ b/src/components/loading-skeleton/LoadingSkeletonTypes.tsx
@@ -0,0 +1,26 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+export enum LoadingSkeletonVariety {
+ Text = 'text',
+ Rectangle = 'rectangle',
+ Disk = 'disk',
+ Paragraph = 'paragraph',
+}
diff --git a/src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx b/src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx
new file mode 100644
index 000000000..cb2338c1a
--- /dev/null
+++ b/src/components/loading-skeleton/__tests__/LoadingSkeleton-test.tsx
@@ -0,0 +1,52 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { matchers } from '@emotion/jest';
+import { screen } from '@testing-library/react';
+import { render } from '~common/helpers/test-utils';
+import { LoadingSkeleton, LoadingSkeletonVariety } from '..';
+
+expect.extend(matchers);
+
+it.each([
+ [LoadingSkeletonVariety.Disk],
+ [LoadingSkeletonVariety.Paragraph],
+ [LoadingSkeletonVariety.Rectangle],
+ [LoadingSkeletonVariety.Text],
+])('%s should render correctly', async (variety) => {
+ const { container } = render();
+
+ await expect(container).toHaveNoA11yViolations();
+});
+
+it('should hide text content when loading', () => {
+ render(content);
+
+ expect(screen.queryByText('content')).not.toBeInTheDocument();
+});
+
+it('should show text content when not loading', () => {
+ render(
+
+ content
+ ,
+ );
+
+ expect(screen.getByText('content')).toBeInTheDocument();
+});
diff --git a/src/components/loading-skeleton/index.ts b/src/components/loading-skeleton/index.ts
new file mode 100644
index 000000000..7c202eb72
--- /dev/null
+++ b/src/components/loading-skeleton/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton';
+export { LoadingSkeletonVariety } from './LoadingSkeletonTypes';
diff --git a/src/components/typography/Heading.tsx b/src/components/typography/Heading.tsx
index 7e7f9589d..37d51aa8b 100644
--- a/src/components/typography/Heading.tsx
+++ b/src/components/typography/Heading.tsx
@@ -57,7 +57,7 @@ const defaultSizeByTag: Record = {
h5: HeadingSize.ExtraSmall,
};
-const StyledHeading = styled.div>>`
+export const StyledHeading = styled.div>>`
font: ${getHeadingFont};
letter-spacing: ${cssVar('letter-spacing-decreased')};
color: ${cssVar('color-text-strong')};
diff --git a/stories/LoadingSkeleton-stories.tsx b/stories/LoadingSkeleton-stories.tsx
new file mode 100644
index 000000000..4f4be48bd
--- /dev/null
+++ b/stories/LoadingSkeleton-stories.tsx
@@ -0,0 +1,215 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { css } from '@emotion/react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { ReactElement } from 'react';
+import { Card, Pagination, RatingBadge } from '../src/components';
+import { LoadingSkeleton, LoadingSkeletonVariety } from '../src/components/loading-skeleton';
+import { Heading, Label, Text, TextSize } from '../src/components/typography';
+import { basicWrapperDecorator } from './helpers/BasicWrapper';
+
+const meta: Meta = {
+ component: LoadingSkeleton,
+ title: 'Echoes/LoadingSkeleton',
+ argTypes: {},
+ decorators: [basicWrapperDecorator],
+ parameters: {
+ controls: { exclude: ['children'] },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ children: ,
+ isLoading: true,
+ variety: LoadingSkeletonVariety.Text,
+ },
+};
+
+interface WithinTextStoryArgs {
+ isLoading: boolean;
+ size: TextSize;
+ variety: LoadingSkeletonVariety.Text | LoadingSkeletonVariety.Paragraph;
+}
+
+export const WithinText: StoryObj = {
+ parameters: {
+ controls: { exclude: ['children'] },
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: Object.values(TextSize),
+ },
+ variety: { options: [LoadingSkeletonVariety.Text, LoadingSkeletonVariety.Paragraph] },
+ },
+ args: {
+ isLoading: true,
+ size: TextSize.Default,
+ variety: LoadingSkeletonVariety.Text,
+ },
+ render(args) {
+ const { size, ...skeletonArgs } = args;
+ return (
+