diff --git a/DotSpinner/DotSpinner.js b/DotSpinner/DotSpinner.js
new file mode 100644
index 000000000..f406ff5fa
--- /dev/null
+++ b/DotSpinner/DotSpinner.js
@@ -0,0 +1,146 @@
+/**
+ * Provides a Limestone-themed three-dot indeterminate progress indicator.
+ *
+ * @example
+ * Loading...
+ *
+ * @module limestone/DotSpinner
+ * @exports DotSpinner
+ * @exports DotSpinnerBase
+ * @exports DotSpinnerDecorator
+ */
+import kind from '@enact/core/kind';
+import Pure from '@enact/ui/internal/Pure';
+import PropTypes from 'prop-types';
+import compose from 'ramda/src/compose';
+
+import Marquee from '../Marquee';
+import Skinnable from '../Skinnable';
+
+import componentCss from './DotSpinner.module.less';
+
+/**
+ * A component that renders three animated dots.
+ *
+ * @class DotSpinnerBase
+ * @memberof limestone/DotSpinner
+ * @ui
+ * @public
+ */
+const DotSpinnerBase = kind({
+ name: 'DotSpinner',
+
+ propTypes: /** @lends limestone/DotSpinner.DotSpinnerBase.prototype */ {
+ /**
+ * Customizes the component by mapping the supplied collection of CSS class names to the
+ * corresponding internal elements and states of this component.
+ *
+ * The following classes are supported:
+ *
+ * * `dotSpinner` - The root component class
+ *
+ * @type {Object}
+ * @public
+ */
+ css: PropTypes.object,
+
+ /**
+ * Pauses the animation.
+ *
+ * @type {Boolean}
+ * @default false
+ * @public
+ */
+ paused: PropTypes.bool,
+
+ /**
+ * The size of the spinner.
+ *
+ * @type {('medium'|'small')}
+ * @default 'medium'
+ * @public
+ */
+ size: PropTypes.oneOf(['medium', 'small']),
+
+ /**
+ * Removes the background color (making it transparent).
+ *
+ * @type {Boolean}
+ * @default false
+ * @public
+ */
+ transparent: PropTypes.bool
+ },
+
+ defaultProps: {
+ paused: false,
+ size: 'medium',
+ transparent: false
+ },
+
+ styles: {
+ css: componentCss,
+ className: 'dotSpinner',
+ publicClassNames: ['dotSpinner']
+ },
+
+ computed: {
+ 'aria-label': ({['aria-label']: aria, children}) => aria || (!children ? 'Loading' : null),
+ className: ({children, paused, size, transparent, styler}) =>
+ styler.append(size, {content: !!children, paused, transparent})
+ },
+
+ render: ({children, css, ...rest}) => {
+ delete rest.paused;
+ delete rest.size;
+ delete rest.transparent;
+
+ return (
+
+
+ {children ?
+
:
+ null
+ }
+
+ );
+ }
+});
+
+/**
+ * Limestone-specific DotSpinner behaviors.
+ *
+ * @hoc
+ * @memberof limestone/DotSpinner
+ * @mixes limestone/Skinnable.Skinnable
+ * @public
+ */
+const DotSpinnerDecorator = compose(
+ Pure,
+ Skinnable
+);
+
+/**
+ * A Limestone-styled three-dot Spinner.
+ *
+ * @class DotSpinner
+ * @memberof limestone/DotSpinner
+ * @extends limestone/DotSpinner.DotSpinnerBase
+ * @mixes limestone/DotSpinner.DotSpinnerDecorator
+ * @ui
+ * @public
+ */
+const DotSpinner = DotSpinnerDecorator(DotSpinnerBase);
+
+export default DotSpinner;
+export {
+ DotSpinner,
+ DotSpinnerBase,
+ DotSpinnerDecorator
+};
diff --git a/DotSpinner/DotSpinner.module.less b/DotSpinner/DotSpinner.module.less
new file mode 100644
index 000000000..cbb272080
--- /dev/null
+++ b/DotSpinner/DotSpinner.module.less
@@ -0,0 +1,203 @@
+// DotSpinner.module.less
+//
+@import "../styles/mixins.less";
+@import "../styles/variables.less";
+@import "../styles/skin.less";
+
+@dot-spinner-duration: 4s;
+@dot-spinner-dot-size: 20px;
+@dot-spinner-gap: 12px;
+
+@keyframes dot-show-second {
+ 0% { opacity: 0; }
+ 9% { opacity: 0; }
+ 17% { opacity: 1; }
+ 91% { opacity: 1; }
+ 99% { opacity: 0; }
+ 100% { opacity: 0; }
+}
+
+@keyframes dot-show-third {
+ 0% { opacity: 0; }
+ 17% { opacity: 0; }
+ 25% { opacity: 1; }
+ 75% { opacity: 1; }
+ 83% { opacity: 0; }
+ 100% { opacity: 0; }
+}
+
+@keyframes dot-show-first {
+ 0% { opacity: 0; }
+ 1% { opacity: 0; }
+ 9% { opacity: 1; }
+ 83% { opacity: 1; }
+ 91% { opacity: 0; }
+ 100% { opacity: 0; }
+}
+
+@keyframes dot-rotate-first {
+ 0% {
+ transform: rotate(0deg) translateX(0);
+ transform-origin: 26px center;
+ }
+ 25% {
+ transform: rotate(0deg) translateX(0);
+ transform-origin: 26px center;
+ }
+ 37.5% {
+ transform: rotate(180deg) translateX(0);
+ transform-origin: 26px center;
+ }
+ 37.51% {
+ transform: rotate(0deg) translateX(32px);
+ transform-origin: 58px center;
+ }
+ 50% {
+ transform: rotate(-180deg) translate(32px);
+ transform-origin: 58px center;
+ }
+ 62.5% {
+ transform: rotate(-180deg) translate(32px);
+ transform-origin: 58px center;
+ }
+ 75% {
+ transform: rotate(-360deg) translateX(32px);
+ transform-origin: 58px center;
+ }
+ 100% {
+ transform: rotate(-360deg) translate(32px);
+ transform-origin: 58px center;
+ }
+}
+
+@keyframes dot-rotate-second {
+ 0% {
+ transform: rotate(0turn) translateX(0);
+ transform-origin: -6px center;
+ }
+ 25% {
+ transform: rotate(0turn) translateX(0);
+ transform-origin: -6px center;
+ }
+ 37.5% {
+ transform: rotate(180deg) translateX(0);
+ transform-origin: -6px center;
+ }
+ 50% {
+ transform: rotate(180deg) translateX(0);
+ transform-origin: -6px center;
+ }
+ 62.5% {
+ transform: rotate(360deg) translateX(0);
+ transform-origin: -6px center;
+ }
+ 62.51% {
+ transform: rotate(0deg) translateX(0);
+ transform-origin: 26px center;
+ }
+ 75% {
+ transform: rotate(-180deg) translateX(0);
+ transform-origin: 26px center;
+ }
+ 100% {
+ transform: rotate(-180deg) translate(0);
+ transform-origin: 26px center;
+ }
+}
+
+@keyframes dot-rotate-third {
+ 0% {
+ transform: rotate(0turn) translateX(0);
+ transform-origin: -6px center;
+ }
+ 37.5% {
+ transform: rotate(0turn) translateX(0);
+ transform-origin: -6px center;
+ }
+ 50% {
+ transform: rotate(-180deg) translateX(0);
+ transform-origin: -6px center;
+ }
+ 50.01% {
+ transform: rotate(0deg) translateX(-32px);
+ transform-origin: -38px center;
+ }
+ 62.5% {
+ transform: rotate(180deg) translateX(-32px);
+ transform-origin: -38px center;
+ }
+ 100% {
+ transform: rotate(180deg) translateX(-32px);
+ transform-origin: -38px center;
+ }
+}
+
+.dotSpinner {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ vertical-align: middle;
+
+ .dots {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: @dot-spinner-gap;
+ }
+
+ .dot {
+ width: @dot-spinner-dot-size;
+ height: @dot-spinner-dot-size;
+ border-radius: 50%;
+ background-color: var(--semantic-color-on-background-main, #ffffff);
+
+ &:nth-child(1) {
+ animation:
+ dot-show-first @dot-spinner-duration ease-in-out infinite,
+ dot-rotate-first @dot-spinner-duration ease-in-out infinite;
+ // dot-hide-first @dot-spinner-duration linear infinite;
+ animation-play-state: running;
+ }
+ &:nth-child(2) {
+ // transform-origin: -6px center;
+ animation:
+ dot-show-second @dot-spinner-duration linear infinite,
+ dot-rotate-second @dot-spinner-duration ease-in-out infinite;
+ animation-play-state: running;
+ }
+ &:nth-child(3) {
+ // transform-origin: -6px center;
+ animation:
+ dot-show-third @dot-spinner-duration linear infinite,
+ dot-rotate-third @dot-spinner-duration ease-in-out infinite;
+ animation-play-state: running;
+ }
+ }
+
+ &.paused .dot {
+ animation-play-state: paused;
+ }
+
+ .client {
+ .lime-body-text();
+ align-content: center;
+ height: @lime-spinner-client-height;
+ font-weight: @lime-spinner-font-weight;
+ line-height: @lime-spinner-client-line-height;
+ max-width: 696px;
+ margin-top: 16px;
+ }
+
+ // Skin colors
+ .applySkins({
+ color: @lime-spinner-label-color;
+
+ .dot {
+ background-color: @lime-spinner-color;
+ }
+
+ &.transparent .dot {
+ background-color: @lime-spinner-color;
+ }
+ });
+}
\ No newline at end of file
diff --git a/DotSpinner/package.json b/DotSpinner/package.json
new file mode 100644
index 000000000..f5f38b65e
--- /dev/null
+++ b/DotSpinner/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "DotSpinner.js"
+}
diff --git a/samples/sampler/stories/default/DotSpinner.js b/samples/sampler/stories/default/DotSpinner.js
new file mode 100644
index 000000000..a36ca5409
--- /dev/null
+++ b/samples/sampler/stories/default/DotSpinner.js
@@ -0,0 +1,46 @@
+import DotSpinner, {DotSpinnerBase} from '@enact/limestone/DotSpinner';
+import {mergeComponentMetadata} from '@enact/storybook-utils';
+import {boolean, select, text} from '@enact/storybook-utils/addons/controls';
+import ri from '@enact/ui/resolution';
+
+DotSpinner.displayName = 'DotSpinner';
+const Config = mergeComponentMetadata('DotSpinner', DotSpinnerBase, DotSpinner);
+
+export default {
+ title: 'Limestone/DotSpinner',
+ component: 'DotSpinner'
+};
+
+export const _DotSpinner = (args) => (
+
+
+ {args['content']}
+
+
+);
+
+boolean('paused', _DotSpinner, Config);
+select('size', _DotSpinner, [null, 'medium', 'small'], Config);
+boolean('transparent', _DotSpinner, Config);
+text('content', _DotSpinner, Config, '');
+
+_DotSpinner.storyName = 'DotSpinner';
+_DotSpinner.parameters = {
+ info: {
+ text: 'Three-dot bouncing spinner for loading states'
+ }
+};