diff --git a/pywb/static/loading-spinner/loading-spinner.js b/pywb/static/loading-spinner/loading-spinner.js
new file mode 100644
index 00000000..eddaee34
--- /dev/null
+++ b/pywb/static/loading-spinner/loading-spinner.js
@@ -0,0 +1,156 @@
+class LoadingSpinner {
+ static #instanceCount = 0;
+ constructor(config={}) {
+ this.config = {initialState:true, animationDuration:500, text:'Loading...', ...config};
+
+ if (LoadingSpinner.#instanceCount > 0) {
+ throw new Error('Cannot make a second loading spinner (aka progress indicator)');
+ }
+ LoadingSpinner.#instanceCount++;
+
+
+ const uuid = Math.floor(Math.random()*1000);
+ this.classes = {
+ el: `loading-spinner-${uuid}`,
+ mask: `loading-spinner-mask-${uuid}`,
+ hidden: `hidden-${uuid}`,
+ spinning: `spinning-${uuid}`
+ };
+
+ this.state = config.initialState;
+ this.addStyles();
+ this.addDom();
+ }
+
+ toggle() {
+ if (this.state) {
+ this.setOn();
+ } else {
+ this.setOff();
+ }
+ }
+
+ setOn() {
+ this.state = true;
+ this.el.classList.remove(this.classes.hidden);
+ setTimeout(function setSpinning() {
+ this.el.classList.add(this.classes.spinning);
+ }.bind(this), 10);
+ }
+
+ setOff() {
+ this.state = false;
+ this.el.classList.remove(this.classes.spinning);
+ setTimeout(function setHidden() {
+ this.el.classList.add(this.classes.hidden);
+ }.bind(this), this.config.animationDuration);
+ }
+
+ addDom() {
+ const text = this.config.text;
+ const dom = `
+
`;
+ const wrapEl = document.createElement('div');
+ wrapEl.innerHTML = dom;
+ this.el = wrapEl.firstElementChild;
+ document.getElementsByTagName('body')[0].appendChild(this.el);
+ }
+
+ addStyles() {
+ const duration = this.config.animationDuration;
+ const stylesheetEl = document.createElement('style');
+ document.head.appendChild(stylesheetEl);
+
+ const rules = [`
+.${this.classes.mask} {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 900;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ background-color: rgba(255,255,255, .85);
+
+ opacity: 0;
+ transition: opacity ${duration}ms ease-in;
+}`,`
+.${this.classes.mask}.${this.classes.spinning} {
+ opacity: 1;
+}`,`
+.${this.classes.mask}.${this.classes.hidden} {
+ display: none;
+}`,`
+.${this.classes.el} {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 200px;
+ height: 200px;
+}`,`
+[data-loading-spinner^=circle] {
+ position: absolute;
+ margin: 0;
+ border-radius: 50%;
+ border-left-color: transparent;
+ border-right-color: transparent;
+}`,`
+[data-loading-spinner=circle1] {
+ border: 3px solid #444444;/* #0D4B9F; */
+ width: 70%;
+ height: 70%;
+ animation: rotate 2s cubic-bezier(0.26, 1.36, 0.74, -0.29) infinite;
+}`,`
+[data-loading-spinner=circle2] {
+ border: 3px solid #ddd;/* #E0EDFF; */
+ width: 80%;
+ height: 80%;
+ animation: rotateReverse 2s cubic-bezier(0.26, 1.36, 0.74, -0.29) infinite;
+}`,`
+[data-loading-spinner=circle3] {
+ border: 3px solid #656565;/* #005CDC; */
+ width: 90%;
+ height: 90%;
+ animation: rotate 2s cubic-bezier(0.26, 1.36, 0.74, -0.29) infinite;
+}`,`
+[data-loading-spinner=circle4] {
+ border: 3px solid #aaa; /* #94B6E5; */
+ width: 100%;
+ height: 100%;
+ animation: rotateReverse 2s cubic-bezier(0.26, 1.36, 0.74, -0.29) infinite;
+}`,`
+@keyframes rotate {
+ from {
+ transform: rotateZ(-360deg)
+ }
+ to {
+ transform: rotateZ(0deg)
+ }
+}`,`
+@keyframes rotateReverse {
+ from {
+ transform: rotateZ(360deg)
+ }
+ to {
+ transform: rotateZ(0deg)
+ }
+}`,`
+[data-loading-spinner=text] {
+ font-size: 15px;
+}`];
+ rules.forEach(rule => stylesheetEl.sheet.insertRule(rule));
+ }
+}
\ No newline at end of file
diff --git a/pywb/static/loading-spinner/test.html b/pywb/static/loading-spinner/test.html
new file mode 100644
index 00000000..49a68ca0
--- /dev/null
+++ b/pywb/static/loading-spinner/test.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file