diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
index a7103fdfd3cb..2fa37e3ecd9d 100644
--- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
+++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
@@ -29,6 +29,9 @@ import {
SearchModule,
SectionComponent,
ScrollLayoutDirective,
+ SkeletonComponent,
+ SkeletonTextComponent,
+ SkeletonGroupComponent,
} from "@bitwarden/components";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
@@ -335,6 +338,9 @@ export default {
SectionComponent,
IconButtonModule,
BadgeModule,
+ SkeletonComponent,
+ SkeletonTextComponent,
+ SkeletonGroupComponent,
],
providers: [
{
@@ -594,6 +600,34 @@ export const Loading: Story = {
}),
};
+export const SkeletonLoading: Story = {
+ render: (args) => ({
+ props: { ...args, data: Array(8) },
+ template: /* HTML */ `
+
+
+
+
+
+
Loading...
+
+
+ @for (num of data; track $index) {
+
+
+
+
+
+ }
+
+
+
+
+
+ `,
+ }),
+};
+
export const TransparentHeader: Story = {
render: (args) => ({
props: args,
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts
index d231048563c6..babd5fbfbf70 100644
--- a/libs/components/src/index.ts
+++ b/libs/components/src/index.ts
@@ -36,6 +36,7 @@ export * from "./search";
export * from "./section";
export * from "./select";
export * from "./shared/compact-mode.service";
+export * from "./skeleton";
export * from "./table";
export * from "./tabs";
export * from "./toast";
diff --git a/libs/components/src/skeleton/index.ts b/libs/components/src/skeleton/index.ts
new file mode 100644
index 000000000000..3872cc2a32f7
--- /dev/null
+++ b/libs/components/src/skeleton/index.ts
@@ -0,0 +1,3 @@
+export * from "./skeleton.component";
+export * from "./skeleton-text.component";
+export * from "./skeleton-group.component";
diff --git a/libs/components/src/skeleton/skeleton-group.component.html b/libs/components/src/skeleton/skeleton-group.component.html
new file mode 100644
index 000000000000..d6c88dc73234
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton-group.component.html
@@ -0,0 +1,7 @@
+
diff --git a/libs/components/src/skeleton/skeleton-group.component.ts b/libs/components/src/skeleton/skeleton-group.component.ts
new file mode 100644
index 000000000000..8895397ae897
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton-group.component.ts
@@ -0,0 +1,18 @@
+import { CommonModule } from "@angular/common";
+import { Component } from "@angular/core";
+
+/**
+ * Arranges skeleton loaders into a pre-arranged group that mimics the table and item components.
+ *
+ * Pass skeleton loaders into the start, default, and end content slots. The content within each slot
+ * is fully customizable.
+ */
+@Component({
+ selector: "bit-skeleton-group",
+ templateUrl: "./skeleton-group.component.html",
+ imports: [CommonModule],
+ host: {
+ class: "tw-block",
+ },
+})
+export class SkeletonGroupComponent {}
diff --git a/libs/components/src/skeleton/skeleton-group.stories.ts b/libs/components/src/skeleton/skeleton-group.stories.ts
new file mode 100644
index 000000000000..ae13dad1274a
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton-group.stories.ts
@@ -0,0 +1,73 @@
+import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
+
+import { SharedModule } from "../shared/shared.module";
+
+import { SkeletonGroupComponent } from "./skeleton-group.component";
+import { SkeletonTextComponent } from "./skeleton-text.component";
+import { SkeletonComponent } from "./skeleton.component";
+
+export default {
+ title: "Component Library/Skeleton/Skeleton Group",
+ component: SkeletonGroupComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [SharedModule, SkeletonTextComponent, SkeletonComponent],
+ }),
+ ],
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+
+
+ `,
+ }),
+};
+
+export const NoEndSlot: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+
+ `,
+ }),
+};
+
+export const NoStartSlot: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+
+ `,
+ }),
+};
+
+export const CustomContent: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
diff --git a/libs/components/src/skeleton/skeleton-text.component.html b/libs/components/src/skeleton/skeleton-text.component.html
new file mode 100644
index 000000000000..f29eaca07efb
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton-text.component.html
@@ -0,0 +1,12 @@
+
+ @for (line of this.linesArray(); track $index; let last = $last, first = $first) {
+
+ }
+
diff --git a/libs/components/src/skeleton/skeleton-text.component.ts b/libs/components/src/skeleton/skeleton-text.component.ts
new file mode 100644
index 000000000000..04c61d3e5be7
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton-text.component.ts
@@ -0,0 +1,31 @@
+import { CommonModule } from "@angular/common";
+import { Component, computed, input } from "@angular/core";
+
+import { SkeletonComponent } from "./skeleton.component";
+
+/**
+ * Specific skeleton component used to represent lines of text. It uses the `bit-skeleton`
+ * under the hood.
+ *
+ * Customize the number of lines represented with the `lines` input. Customize the width
+ * by applying a class to the `bit-skeleton-text` element (i.e. `tw-w-1/2`).
+ */
+@Component({
+ selector: "bit-skeleton-text",
+ templateUrl: "./skeleton-text.component.html",
+ imports: [CommonModule, SkeletonComponent],
+ host: {
+ class: "tw-block",
+ },
+})
+export class SkeletonTextComponent {
+ /**
+ * The number of text lines to display
+ */
+ readonly lines = input(1);
+
+ /**
+ * Array-transformed version of the `lines` to loop over
+ */
+ protected linesArray = computed(() => Array.from(Array(this.lines()).keys()));
+}
diff --git a/libs/components/src/skeleton/skeleton-text.stories.ts b/libs/components/src/skeleton/skeleton-text.stories.ts
new file mode 100644
index 000000000000..b74f8bef4443
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton-text.stories.ts
@@ -0,0 +1,46 @@
+import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
+
+import { SharedModule } from "../shared/shared.module";
+
+import { SkeletonTextComponent } from "./skeleton-text.component";
+
+export default {
+ title: "Component Library/Skeleton/Skeleton Text",
+ component: SkeletonTextComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [SharedModule],
+ }),
+ ],
+ args: {
+ lines: 1,
+ },
+ argTypes: {
+ lines: {
+ control: { type: "number", min: 1 },
+ },
+ },
+} as Meta;
+
+type Story = StoryObj;
+
+export const Text: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ `,
+ }),
+};
+
+export const TextMultiline: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ `,
+ }),
+ args: {
+ lines: 5,
+ },
+};
diff --git a/libs/components/src/skeleton/skeleton.component.html b/libs/components/src/skeleton/skeleton.component.html
new file mode 100644
index 000000000000..2b0a1f7aba14
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton.component.html
@@ -0,0 +1,8 @@
+
diff --git a/libs/components/src/skeleton/skeleton.component.ts b/libs/components/src/skeleton/skeleton.component.ts
new file mode 100644
index 000000000000..a9d83dd80a50
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton.component.ts
@@ -0,0 +1,26 @@
+import { CommonModule } from "@angular/common";
+import { Component, input } from "@angular/core";
+
+/**
+ * Basic skeleton loading component that can be used to represent content that is loading.
+ * Use for layout-level elements and text, not for interactive elements.
+ *
+ * Customize the shape's edges with the `edgeShape` input. Customize the shape's size by
+ * applying classes to the `bit-skeleton` element (i.e. `tw-w-40 tw-h-8`).
+ *
+ * If you're looking to represent lines of text, use the `bit-skeleton-text` helper component.
+ */
+@Component({
+ selector: "bit-skeleton",
+ templateUrl: "./skeleton.component.html",
+ imports: [CommonModule],
+ host: {
+ class: "tw-block",
+ },
+})
+export class SkeletonComponent {
+ /**
+ * The shape of the corners of the skeleton element
+ */
+ readonly edgeShape = input<"box" | "circle">("box");
+}
diff --git a/libs/components/src/skeleton/skeleton.mdx b/libs/components/src/skeleton/skeleton.mdx
new file mode 100644
index 000000000000..ea41020664fb
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton.mdx
@@ -0,0 +1,74 @@
+import { Meta, Canvas, Source } from "@storybook/addon-docs";
+
+import * as skeletonStories from "./skeleton.stories";
+import * as skeletonTextStories from "./skeleton-text.stories";
+import * as skeletonGroupStories from "./skeleton-group.stories";
+
+
+
+# Skeleton Loading
+
+The skeleton component can be used as an alternative loading indicator to the spinner by mimicking
+the content that will be loaded such as text, images, or video. It can be used to represent layout
+components as well, but should not be used for interactive elements like form controls or buttons.
+
+## Skeleton Loading Components
+
+There are three components that can be used to create a skeleton loading page.
+
+### Skeleton
+
+Basic skeleton loading component that can be used to represent content that is loading. The shape of
+the edges is configurable to be either squared or round. Use for non-text shapes.
+
+
+
+
+### Skeleton Text
+
+Specific skeleton component used to represent lines of text. The number of lines is configurable.
+
+
+
+
+### Skeleton Group
+
+Arranges skeleton loaders into a pre-arranged group that mimics the table and item components. It
+uses start, default, and end slots to allow any skeleton content to be rendered.
+
+
+
+## Display Considerations
+
+For pages that load quickly, we want to avoid the skeleton flashing in and out. To avoid this, we
+recommend the following display guidelines:
+
+- After the loading is initiated (by page load or by user action), wait 1 second to display the
+ skeleton loader.
+- After waiting 1s, render the loading skeleton.
+- Ideally the skeleton disappears after 10 seconds, but we do not enforce a max duration. Add a max
+ duration at your discretion.
+
+## Accessibility
+
+Because there are typically multiple skeleton loaders present on a page that is using skeleton
+loading, the individual skeleton loaders should not announce themselves or be present to
+screenreaders, as this would overwhelm the user with multiple identical announcements. Thus, the
+skeleton components are hidden from screenreaders.
+
+Instead, the recommended strategy is to use a page-level announcement for screenreaders:
+
+- We recommend using the
+ [Angular CDK LiveAnnouncer](https://material.angular.dev/cdk/a11y/overview#liveannouncer) to first
+ announce that content is loading when the skeleton loader is displayed, and then to announce that
+ content has loaded. The announcements should be localized, and the politeness level should be set
+ to `polite`.
+
+- Alternatively, you may wish to render your own `role="status"` element or a custom `aria-live`
+ region in the template to accomplish the announcements detailed above.
+
+## Example with Browser Extension
+
+To see a full-page example of what skeleton loading might look like using all three skeleton
+components, check the
+[Popup Layout Skeleton Loading story](?path=/docs/browser-popup-layout--skeleton-loading).
diff --git a/libs/components/src/skeleton/skeleton.stories.ts b/libs/components/src/skeleton/skeleton.stories.ts
new file mode 100644
index 000000000000..0198e95e882c
--- /dev/null
+++ b/libs/components/src/skeleton/skeleton.stories.ts
@@ -0,0 +1,74 @@
+import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
+
+import { SharedModule } from "../shared/shared.module";
+
+import { SkeletonComponent } from "./skeleton.component";
+
+export default {
+ title: "Component Library/Skeleton/Skeleton",
+ component: SkeletonComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [SharedModule],
+ }),
+ ],
+ args: {
+ edgeShape: "box",
+ },
+ argTypes: {
+ edgeShape: {
+ control: { type: "radio" },
+ options: ["box", "circle"],
+ },
+ },
+} as Meta;
+
+type Story = StoryObj;
+
+export const Square: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ `,
+ }),
+ args: {
+ edgeShape: "box",
+ },
+};
+
+export const Rectangle: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ `,
+ }),
+ args: {
+ edgeShape: "box",
+ },
+};
+
+export const Circle: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ `,
+ }),
+ args: {
+ edgeShape: "circle",
+ },
+};
+
+export const Oval: Story = {
+ render: (args) => ({
+ props: args,
+ template: /*html*/ `
+
+ `,
+ }),
+ args: {
+ edgeShape: "circle",
+ },
+};