diff --git a/src/ink/images/spinner/default.svg b/src/ink/images/spinner/default.svg
new file mode 100644
index 0000000000000000000000000000000000000000..478db306ccec5df9969acb312d0ca425ca398c8e
--- /dev/null
+++ b/src/ink/images/spinner/default.svg
@@ -0,0 +1,10 @@
+<svg width="100%" height="100%" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" style="stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
+ <path d="M11.514,14.035c-1.032,0.603 -2.233,0.948 -3.514,0.948c-3.854,0 -6.983,-3.129 -6.983,-6.983c0,-3.854 3.129,-6.983 6.983,-6.983" style="fill:none;stroke:#000;stroke-width:2px;"/>
+ <animateTransform
+   attributeName="transform"
+   type="rotate"
+   from="0 0 0"
+   to="360 0 0"
+   dur="1.0s"
+   repeatCount="indefinite"/>
+</svg>
diff --git a/src/ink/js/components/views/index.ts b/src/ink/js/components/views/index.ts
index 6049421a110f1a3fc21fea6913d812cb32bcd6cd..d934590771787a73d156ab63d834a9d9f66fafd2 100644
--- a/src/ink/js/components/views/index.ts
+++ b/src/ink/js/components/views/index.ts
@@ -2,3 +2,4 @@ export * from './baseComponentView';
 export * from './buttonGroupView';
 export * from './buttonView';
 export * from './keyboardShortcutView';
+export * from './spinnerView';
diff --git a/src/ink/js/components/views/spinnerView.ts b/src/ink/js/components/views/spinnerView.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca44f83dd9a148f32737940fce52dade91b498df
--- /dev/null
+++ b/src/ink/js/components/views/spinnerView.ts
@@ -0,0 +1,60 @@
+/**
+ * A component for a spinner.
+ *
+ * Version Added:
+ *     1.0
+ */
+
+import {
+    BaseModel,
+    BaseView,
+    spina,
+} from '@beanbag/spina';
+
+import { inkComponent } from '../../core';
+import { BaseComponentViewOptions } from './baseComponentView';
+
+
+/**
+ * Options for SpinnerView.
+ *
+ * Version Added:
+ *     1.0
+ */
+export type SpinnerViewOptions = BaseComponentViewOptions;
+
+
+/**
+ * Component for showing a spinner.
+ *
+ * This will show a spinner indicating that an operation is taking place, such
+ * as content loading from a server.
+ *
+ * When using the spinner to represent content loading in a parent element,
+ * it's best to set ``aria-busy="true"`` on that element in order to inform
+ * screen readers that the content is still loading. It's very important to
+ * remove this attribute once the load has completed.
+ *
+ * This should be used along with ``aria-live=`` to indicate that the
+ * element will be updated.
+ *
+ * Alternatively, you might want to use ``aria-hidden="false"`` on this, if
+ * it should not be read or seen by the screen reader, or ``aria-label="..."``
+ * if it should (and does not otherwise have accompanying text).
+ *
+ * Version Added:
+ *     1.0
+ */
+@inkComponent('Ink.Spinner')
+@spina
+export class SpinnerView<
+    T extends BaseModel = BaseModel,
+    OptionsT extends SpinnerViewOptions = SpinnerViewOptions,
+> extends BaseView<
+    T,
+    HTMLSpanElement,
+    OptionsT
+> {
+    static tagName = 'span';
+    static className = 'ink-c-spinner';
+}
diff --git a/src/ink/js/components/views/tests/spinnerViewTests.ts b/src/ink/js/components/views/tests/spinnerViewTests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..313a1366545bff4dcca3fc33ca1afc9e5210fe87
--- /dev/null
+++ b/src/ink/js/components/views/tests/spinnerViewTests.ts
@@ -0,0 +1,13 @@
+import { suite } from '@beanbag/jasmine-suites';
+import 'jasmine';
+
+import { paint } from '../../../index';
+
+
+suite('components/views/ButtonView', () => {
+    it('Render', () => {
+        const el = paint<HTMLSpanElement>`<Ink.Spinner/>`;
+
+        expect(el.outerHTML).toBe('<span class="ink-c-spinner"></div>');
+    });
+});
diff --git a/src/ink/less/components/index.less b/src/ink/less/components/index.less
index c384c437f48e8173510df959c6f233798c879896..1fbd4d62329f651e439e763f929e0d0949ef28f4 100644
--- a/src/ink/less/components/index.less
+++ b/src/ink/less/components/index.less
@@ -3,3 +3,4 @@
 @import "./button.component.less";
 @import "./button-group.component.less";
 @import "./keyboard-shortcut.component.less";
+@import "./spinner.component.less";
diff --git a/src/ink/less/components/schemas/index.less b/src/ink/less/components/schemas/index.less
index 9f8538aae13c252bb42a1be225e670e1f20ba30c..1ca030163eeb9571bcb2fa0593207016cfe77f12 100644
--- a/src/ink/less/components/schemas/index.less
+++ b/src/ink/less/components/schemas/index.less
@@ -3,3 +3,4 @@
 @import "./button.schema.less";
 @import "./button-group.schema.less";
 @import "./keyboard-shortcut.schema.less";
+@import "./spinner.schema.less";
diff --git a/src/ink/less/components/schemas/spinner.schema.less b/src/ink/less/components/schemas/spinner.schema.less
new file mode 100644
index 0000000000000000000000000000000000000000..a09e061ba0c39a45112c9b2452e2f4dc80bddb1c
--- /dev/null
+++ b/src/ink/less/components/schemas/spinner.schema.less
@@ -0,0 +1,71 @@
+/**
+ * Schema for a spinner component.
+ *
+ * Version Added:
+ *     1.0
+ */
+
+@import (reference) "./base.less";
+
+
+#ink-ns-schema() {
+  @spinner: {
+    /**
+     * Populate a spinner schema.
+     *
+     * This will provide the structure for a spinner component, populating the
+     * parts of the component with the provided style rules.
+     *
+     * Version Added:
+     *     1.0
+     *
+     * Args:
+     *     @name (string):
+     *         The name of the CSS class that this schema would populate.
+     *
+     *     @schema-rulees (ruleset, optional):
+     *         Any rules to apply to part of the schema.
+     *
+     *     @specialize-schema-rulees (ruleset, optional):
+     *         Any additional specialized rules to apply to part of the schema.
+     *
+     *         This is a convenience to allow themes or specializations of
+     *         components to easily inject additional rules into the schema.
+     */
+    .populate(@name;
+              @schema-rules: null;
+              @specialize-rules: null) {
+      #ink-ns-schema.base();
+
+      /**
+       * Main part of the spinner component.
+       *
+       * Spinners represent an in-progress activity, such as new content being
+       * loaded or a request being sent to a server.
+       *
+       * Accessibility Notes:
+       *     When using the spinner to represent content loading in a parent
+       *     element, it's best to set ``aria-busy="true"`` on that element
+       *     in order to inform screen readers that the content is still
+       *     loading. It's very important to remove this attribute once the
+       *     load has completed.
+       *
+       *     This should be used along with ``aria-live=`` to indicate that the
+       *     element will be updated.
+       *
+       *     Alternatively, you might want to use ``aria-hidden="false"`` on
+       *     this, if it should not be read or seen by the screen reader, or
+       *     ``aria-label="..."`` if it should (and does not otherwise have
+       *     accompanying text).
+       *
+       * Structure:
+       *     <span class="@{name}"
+       *           [aria-hidden="true"]
+       *           [aria-label="..." | aria-labelledby="..."]></span>
+       */
+      & {
+        .add-rules('__default__');
+      }
+    }
+  }
+}
diff --git a/src/ink/less/components/spinner.component.less b/src/ink/less/components/spinner.component.less
new file mode 100644
index 0000000000000000000000000000000000000000..4d52128341d439ed67e1d008e37371c874d2fcfa
--- /dev/null
+++ b/src/ink/less/components/spinner.component.less
@@ -0,0 +1,48 @@
+/**
+ * Component management for ink-c-spinner.
+ *
+ * Version Added:
+ *     1.0
+ */
+
+#ink-ns-ui() {
+  .spinner() {
+    /**
+     * Create an ink-c-spinner component.
+     *
+     * Version Added:
+     *     1.0
+     *
+     * Args:
+     *     @name (string, optional):
+     *         The name of the CSS class for the component.
+     *
+     *     @vars (ruleset, optional):
+     *         Any CSS variables to register on the component and root element.
+     *
+     *     @rulees (ruleset, optional):
+     *         Any custom rules to apply to the schema.
+     */
+    .create(@name: ink-c-spinner;
+            @vars: null;
+            @rules: null) {
+      #ink-ns-ui.base.define-component(
+        @name: @name;
+        @vars: @vars;
+        @specialize-rules: @rules;
+        @schema: #ink-ns-schema[@spinner];
+        @schema-rules: {
+          /*
+           * Styles for the default state of the main element.
+           */
+          @__default__: {
+            display: inline-block;
+            align-self: center;
+            justify-self: center;
+            vertical-align: middle;
+          };
+        };
+      );
+    }
+  }
+}
diff --git a/src/ink/less/themes/default/components/index.less b/src/ink/less/themes/default/components/index.less
index d144ba2211b378197f777affad5d3e063271a876..67c625ea18dd96e957dcef656a94f240702a0230 100644
--- a/src/ink/less/themes/default/components/index.less
+++ b/src/ink/less/themes/default/components/index.less
@@ -1,3 +1,4 @@
 @import "./button.theme.less";
 @import "./button-group.theme.less";
 @import "./keyboard-shortcut.theme.less";
+@import "./spinner.theme.less";
diff --git a/src/ink/less/themes/default/components/spinner.theme.less b/src/ink/less/themes/default/components/spinner.theme.less
new file mode 100644
index 0000000000000000000000000000000000000000..401a555ed081728222bfd8ede004d882843cca3a
--- /dev/null
+++ b/src/ink/less/themes/default/components/spinner.theme.less
@@ -0,0 +1,44 @@
+@import (reference) "../../../components/spinner.component.less";
+
+
+#ink-ns-default-theme() {
+  .spinner() {
+    @vars: {
+      #ink-ns-theme.add-css-vars(
+        @prefix: ink-c-spinner;
+        @vars: {
+          /*
+           * This value will help this fit in well with text, keeping the
+           * existing height.
+           */
+          size: 1em;
+
+          image: data-uri('@{images-path}/spinner/default.svg');
+        };
+      );
+    };
+
+    @rules: {
+      /*
+       * Styles for the default state of the main spinner element.
+       */
+      @__default__: {
+        background-color: currentColor;
+        background-repeat: no-repeat;
+        mask-image: var(--ink-c-spinner-image);
+        width: var(--ink-c-spinner-size);
+        height: var(--ink-c-spinner-size);
+      };
+    };
+  }
+}
+
+
+& {
+  @_spinner: #ink-ns-default-theme.spinner();
+
+  #ink-ns-ui.spinner.create(
+    @vars: @_spinner[@vars];
+    @rules: @_spinner[@rules];
+  );
+}
diff --git a/src/stories/components/spinner.stories.ts b/src/stories/components/spinner.stories.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5010373cfa50194594966d3fa0efbda4ff47ab3e
--- /dev/null
+++ b/src/stories/components/spinner.stories.ts
@@ -0,0 +1,72 @@
+import {
+    SpinnerView,
+    paint
+} from '../../ink/js';
+
+
+export default {
+    title: 'Ink/Components/Spinner',
+    tags: ['autodocs'],
+    render: ({
+        color,
+        ...args
+    }) => {
+        const el = paint<HTMLSpanElement>`<Ink.Spinner/>`;
+
+        if (color) {
+            el.style.color = color;
+        }
+
+        return el;
+    },
+    argTypes: {
+        color: {
+            control: 'color',
+        },
+    },
+};
+
+
+export const Standard = {};
+
+
+export const Colors = {
+    render: ({
+        color,
+        ...args
+    }) => {
+        const style = window.getComputedStyle(document.documentElement);
+
+        return paint`
+            <div style="display: inline-flex; gap: 1em;">
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-blue-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-red-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-yellow-800')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-green-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-grey-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-brown-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-cyan-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-cool-grey-500')
+             }}/>
+             <Ink.Spinner style=${{
+                 color: style.getPropertyValue('--ink-p-mustard-500')
+             }}/>
+            </div>
+        `;
+    },
+};
