From 1f7f8422d8e9c17d63124726802095f86242957d Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Wed, 21 Jan 2026 16:19:07 +0000 Subject: [PATCH 01/19] Add block --- .../loader.a11y.test.ts} | 6 +- .../lib/components/loader/loader.less | 80 ++++++++++++++ .../loader.visual.test.ts} | 8 +- .../lib/components/spinner/spinner.less | 103 ------------------ .../stacks-classic/lib/stacks-static.less | 2 +- .../_data/{spinner.json => loader.json} | 10 -- .../stacks-docs/_data/site-navigation.json | 8 +- .../components/{spinner.html => loader.html} | 31 ++---- temp_loading_svg.txt | 1 + 9 files changed, 101 insertions(+), 148 deletions(-) rename packages/stacks-classic/lib/components/{spinner/spinner.a11y.test.ts => loader/loader.a11y.test.ts} (69%) create mode 100644 packages/stacks-classic/lib/components/loader/loader.less rename packages/stacks-classic/lib/components/{spinner/spinner.visual.test.ts => loader/loader.visual.test.ts} (87%) delete mode 100644 packages/stacks-classic/lib/components/spinner/spinner.less rename packages/stacks-docs/_data/{spinner.json => loader.json} (69%) rename packages/stacks-docs/product/components/{spinner.html => loader.html} (66%) create mode 100644 temp_loading_svg.txt diff --git a/packages/stacks-classic/lib/components/spinner/spinner.a11y.test.ts b/packages/stacks-classic/lib/components/loader/loader.a11y.test.ts similarity index 69% rename from packages/stacks-classic/lib/components/spinner/spinner.a11y.test.ts rename to packages/stacks-classic/lib/components/loader/loader.a11y.test.ts index 51b9dbf0b7..456b79bd4b 100644 --- a/packages/stacks-classic/lib/components/spinner/spinner.a11y.test.ts +++ b/packages/stacks-classic/lib/components/loader/loader.a11y.test.ts @@ -1,11 +1,11 @@ import { runA11yTests } from "../../test/a11y-test-utils"; import "../../index"; -describe("spinner", () => { +describe("loading", () => { runA11yTests({ - baseClass: "s-spinner", + baseClass: "s-loading", modifiers: { - primary: ["xs", "sm", "md", "lg"], + primary: ["sm", "lg"], }, children: { default: `
Loading…
`, diff --git a/packages/stacks-classic/lib/components/loader/loader.less b/packages/stacks-classic/lib/components/loader/loader.less new file mode 100644 index 0000000000..74209cedd8 --- /dev/null +++ b/packages/stacks-classic/lib/components/loader/loader.less @@ -0,0 +1,80 @@ +.s-loading--block { + // BASE COMPONENT-SPECIFIC CUSTOM PROPERTIES + --_ld-size: var(--su-static24); + --_ld-block-size: calc(var(--su-static4) + var(--su-static1)); + --_ld-bg: var(--black-600); + --_ld-secondary-bg: var(--black-200); + + // MODIFIERS + &&__sm { + --_ld-size: var(--su-static16); + --_ld-block-size: calc((var(--su-static6) + var(--su-static1)) / 2); + } + &&__lg { + --_ld-size: var(--su-static48); + --_ld-block-size: var(--su-static8); + } + + // CHILD ELEMENTS + &:before { + animation: loading-block-animation 1.5s cubic-bezier(1, 1, 0, 1) infinite; + background-color: var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + content: ""; + display: block; + height: var(--_ld-block-size); + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: var(--_ld-block-size); + } + + height: var(--_ld-size); + position: relative; + width: var(--_ld-size); +} + +@keyframes loading-block-animation { + 0% { + background-color: var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, -50%); + } + 16.667% { + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) -5px 0 0 var(--_ld-bg), + calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, -50%); + } + 33.333% { + background-color: var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, -50%); + } + 50% { + background-color: var(--_ld-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 5px 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) 5px 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, calc(-50% - 5px)); + } + 66.667% { + background-color: var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, -50%); + } + 83.333% { + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) -5px 0 0 var(--_ld-bg); + transform: translate(-50%, -50%); + } + 100% { + background-color: var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, -50%); + } +} \ No newline at end of file diff --git a/packages/stacks-classic/lib/components/spinner/spinner.visual.test.ts b/packages/stacks-classic/lib/components/loader/loader.visual.test.ts similarity index 87% rename from packages/stacks-classic/lib/components/spinner/spinner.visual.test.ts rename to packages/stacks-classic/lib/components/loader/loader.visual.test.ts index 7c7c23a217..73827b5c3b 100644 --- a/packages/stacks-classic/lib/components/spinner/spinner.visual.test.ts +++ b/packages/stacks-classic/lib/components/loader/loader.visual.test.ts @@ -6,12 +6,12 @@ import "../../index"; const template = ({ component, testid }: any) => html`
${component}
`; -describe("spinner", () => { +describe("loading", () => { // default, sizes runVisualTests({ - baseClass: "s-spinner", + baseClass: "s-loading", modifiers: { - primary: ["xs", "sm", "md", "lg"], + primary: ["sm", "lg"], }, children: { default: `
Loading…
`, @@ -20,7 +20,7 @@ describe("spinner", () => { }); // applied font color runVisualTests({ - baseClass: "s-spinner", + baseClass: "s-loading", modifiers: { global: ["fc-theme-primary"], }, diff --git a/packages/stacks-classic/lib/components/spinner/spinner.less b/packages/stacks-classic/lib/components/spinner/spinner.less deleted file mode 100644 index 8f30b0e17a..0000000000 --- a/packages/stacks-classic/lib/components/spinner/spinner.less +++ /dev/null @@ -1,103 +0,0 @@ -.s-spinner { - --_sp-baw: calc(var(--su-static1) * 3); // 3px - --_sp-size: var(--su-static24); - - // MODIFIERS - &&__xs { - --_sp-baw: var(--su-static1); - --_sp-size: var(--su-static12); - } - - &&__sm { - --_sp-baw: var(--su-static2); - --_sp-size: var(--su-static16); - } - - &&__md { - --_sp-baw: var(--su-static4); - --_sp-size: var(--su-static32); - } - - &&__lg { - --_sp-baw: var(--su-static6); - --_sp-size: var(--su-static48); - } - - // CHILD ELEMENTS - &:after, - &:before { - border: var(--_sp-baw) solid currentColor; - - border-radius: var(--br-circle); - content: ''; - height: 100%; - position: absolute; - width: 100%; - } - - &:after { - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - animation: s-spinner-rotate 0.9s infinite cubic-bezier(0.5, 0.1, 0.5, 0.9); - } - - &:before { - opacity: 0.25; - transform: rotate(90deg); // [1] - } - - height: var(--_sp-size); - width: var(--_sp-size); - - position: relative; - text-align: left; // [2] -} - -.is-loading { - --_li-offset: 0.6em; - --_il-size: 1.23076923em; - - &:after, - &:before { - border-radius: var(--br-circle); - border-style: solid; - border-width: var(--su-static2); - content: ""; - height: var(--_il-size); - left: var(--_li-offset); - position: absolute; - top: calc(50% - var(--_li-offset)); - width: var(--_il-size); - } - - &:after { - animation: s-spinner-rotate 0.9s infinite cubic-bezier(0.5, 0.1, 0.5, 0.9); - border-color: transparent; - border-left-color: currentColor; - filter: invert(0); // [1] - transform-origin: 50% 50% var(--su-static1); // [1] - } - - &:before { - border-color: currentColor; - opacity: 0.3; - } - - padding-left: 2.2em; - position: relative; -} - -// Keyframes -@keyframes s-spinner-rotate { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -// [1] no-op to reduce wobble in Edge. More info: https://github.com/StackExchange/Stacks/blob/d2af26aca06c47e3f1f7a638e49b221a9e28e450/lib/css/components/_stacks-spinner.less#L16-L26 - -// [2] When within a parent that has text-align: center, the spinner's entire container spins, not just the spinner. Let's force text-align left so things spin internally. diff --git a/packages/stacks-classic/lib/stacks-static.less b/packages/stacks-classic/lib/stacks-static.less index ddb182b0f3..02cd046c2b 100644 --- a/packages/stacks-classic/lib/stacks-static.less +++ b/packages/stacks-classic/lib/stacks-static.less @@ -43,7 +43,7 @@ @import "components/select/select.less"; @import "components/sidebar-widget/sidebar-widget.less"; @import "components/skeleton/skeleton.less"; -@import "components/spinner/spinner.less"; +@import "components/loader/loader.less"; @import "components/table/table.less"; @import "components/table-container/table-container.less"; @import "components/tag/tag.less"; diff --git a/packages/stacks-docs/_data/spinner.json b/packages/stacks-docs/_data/loader.json similarity index 69% rename from packages/stacks-docs/_data/spinner.json rename to packages/stacks-docs/_data/loader.json index 70856d481b..1e9d30d44a 100644 --- a/packages/stacks-docs/_data/spinner.json +++ b/packages/stacks-docs/_data/loader.json @@ -5,21 +5,11 @@ "applies": "N/A", "description": "Base loading style that is used almost universally" }, - { - "class": ".s-spinner__xs", - "applies": ".s-spinner", - "description": "An extra small loading style for compact layouts" - }, { "class": ".s-spinner__sm", "applies": ".s-spinner", "description": "A small style for compact layouts" }, - { - "class": ".s-spinner__md", - "applies": ".s-spinner", - "description": "A medium style for larger layouts" - }, { "class": ".s-spinner__lg", "applies": ".s-spinner", diff --git a/packages/stacks-docs/_data/site-navigation.json b/packages/stacks-docs/_data/site-navigation.json index 6746192f9b..88d08b5020 100644 --- a/packages/stacks-docs/_data/site-navigation.json +++ b/packages/stacks-docs/_data/site-navigation.json @@ -285,6 +285,10 @@ "title": "Link previews", "url": "/product/components/link-previews/" }, + { + "title": "Loader", + "url": "/product/components/loader/" + }, { "title": "Menus", "url": "/product/components/menus/", @@ -339,10 +343,6 @@ "title": "Skeleton", "url": "/product/components/skeleton/" }, - { - "title": "Spinner", - "url": "/product/components/spinner/" - }, { "title": "Tables", "url": "/product/components/tables/" diff --git a/packages/stacks-docs/product/components/spinner.html b/packages/stacks-docs/product/components/loader.html similarity index 66% rename from packages/stacks-docs/product/components/spinner.html rename to packages/stacks-docs/product/components/loader.html index d794d8d4fd..1fba0508ca 100644 --- a/packages/stacks-docs/product/components/spinner.html +++ b/packages/stacks-docs/product/components/loader.html @@ -1,8 +1,8 @@ --- layout: page -title: Spinner +title: Loader svelte: https://beta.svelte.stackoverflow.design/?path=/docs/components-spinner--docs -description: A loading spinner is used for indicating a loading state of a page or section. It is colored according to the currently applying font color. +description: "The Loader indicates an active wait state for a page, section, or interactive element." tags: components ---
@@ -17,7 +17,7 @@ - {% for item in spinner.spinner %} + {% for item in loader.loader %} {{ item.class }} {% if item.applies == "N/A" %}{{ item.applies }}{% else %}{{ item.applies }}{% endif %} @@ -30,20 +30,15 @@
{% header "h2", "Examples" %} -

The spinner’s colors are based on the font color of the element, which will usually be inherited from its parent. If you need to apply a color override, the font color classes can provide themability. In most situations, a black and white spinner based on the default font color will be appropriate.

-

For accessibility, it’s important to add fallback loading text that is visible to screen readers. Additionally, you should add aria-busy="true" to the component that triggered the loading state while the spinner is shown.

+ {% header "h3", "Blocks" %} +

Use the Blocks variant as the standard loader for general UI states. This is the most common style and utilizes a monochrome black and gray palette.

+

For accessibility, it’s important to add fallback loading text that is visible to screen readers. Additionally, you should add aria-busy="true" to the component that triggered the loading state while the loader is shown.

{% highlight html %} -
-
Loading…
-
Loading…
-
-
Loading…
-
-
+
Loading…
@@ -55,23 +50,13 @@ {% endhighlight %}
-
-
-
Loading…
-
-
Loading…
-
-
Loading…
-
-
-
-
+
Loading…
diff --git a/temp_loading_svg.txt b/temp_loading_svg.txt new file mode 100644 index 0000000000..7b711f86af --- /dev/null +++ b/temp_loading_svg.txt @@ -0,0 +1 @@ +data:image/svg+xml,%3Csvg%20width%3D%22190%22%20height%3D%22190%22%20viewBox%3D%220%200%20190%20190%22%20class%3D%22svg-spot%20SpotLoadingGlyph%22%20aria-hidden%3D%22true%22%3E%3Cstyle%3E%40keyframes%20rotate-b1%7B0%25%2C20%25%7Btransform%3Arotate(0deg)%3Btransform-origin%3A117px%20190px%7D30%25%2C35%25%2C45%25%2Cto%7Btransform%3Arotate(15deg)%3Btransform-origin%3A120px%20160px%7D%7D%40keyframes%20rotate-b2%7B0%25%2C20%25%7Btransform%3Arotate(0deg)%3Btransform-origin%3A117px%20190px%7D30%25%2C40%25%7Btransform%3Arotate(15deg)%3Btransform-origin%3A120px%20160px%7D50%25%2C55%25%2C65%25%2Cto%7Btransform%3Arotate(30deg)%3Btransform-origin%3A136px%20147px%7D%7D%40keyframes%20rotate-b3%7B0%25%2C20%25%7Btransform%3Arotate(0deg)%3Btransform-origin%3A117px%20190px%7D30%25%2C40%25%7Btransform%3Arotate(15deg)%3Btransform-origin%3A120px%20160px%7D50%25%2C60%25%7Btransform%3Arotate(30deg)%3Btransform-origin%3A136px%20147px%7D70%25%2C75%25%2C85%25%2Cto%7Btransform%3Arotate(45deg)%3Btransform-origin%3A143px%20136px%7D%7D%40keyframes%20reveal-s1%7B0%25%2C15%25%7Btransform%3Ascale(0)%20rotate(-15deg)%7D25%25%2Cto%7Btransform%3Ascale(1)%20rotate(0deg)%7D%7D%40keyframes%20reveal-s2%7B0%25%2C40%25%7Btransform%3Ascale(0)%7D50%25%2Cto%7Btransform%3Ascale(1)%7D%7D%40keyframes%20reveal-s3%7B0%25%2C60%25%7Btransform%3Ascale(0)%7D70%25%2Cto%7Btransform%3Ascale(1)%7D%7D%40keyframes%20slide-in%7B0%25%7Btransform%3AtranslateY(100%25)%7D33%25%2C67%25%2Cto%7Btransform%3AtranslateY(0)%7D%7D.spot-loadingglyph__b%2C.spot-loadingglyph__g%2C.spot-loadingglyph__s%7Banimation-duration%3A.75s%3Banimation-timing-function%3Acubic-bezier(.33%2C1%2C.66%2C1)%3Banimation-iteration-count%3Ainfinite%3Banimation-direction%3Aalternate%7D.spot-loadingglyph__s%7Btransform-box%3Afill-box%3Btransform-origin%3Acenter%7D.spot-loadingglyph__g%7Banimation-name%3Aslide-in%7D.spot-loadingglyph__b1%7Banimation-name%3Arotate-b1%7D.spot-loadingglyph__b2%7Banimation-name%3Arotate-b2%7D.spot-loadingglyph__b3%7Banimation-name%3Arotate-b3%7D.spot-loadingglyph__s1%7Banimation-name%3Areveal-s1%7D.spot-loadingglyph__s2%7Banimation-name%3Areveal-s2%7D.spot-loadingglyph__s3%7Banimation-name%3Areveal-s3%7D%3C%2Fstyle%3E%3Cg%20class%3D%22spot-loadingglyph__g%22%3E%3Crect%20width%3D%22116%22%20height%3D%2231%22%20x%3D%227%22%20y%3D%22159%22%20fill%3D%22%23ff5e00%22%20class%3D%22spot-loadingglyph__b%20spot-loadingglyph__b0%22%2F%3E%3Crect%20width%3D%22116%22%20height%3D%2231%22%20x%3D%227%22%20y%3D%22129%22%20fill%3D%22%23ff5e00%22%20class%3D%22spot-loadingglyph__b%20spot-loadingglyph__b1%22%2F%3E%3Crect%20width%3D%22116%22%20height%3D%2231%22%20x%3D%227%22%20y%3D%22105%22%20fill%3D%22%23ff5e00%22%20class%3D%22spot-loadingglyph__b%20spot-loadingglyph__b2%22%2F%3E%3Crect%20width%3D%22116%22%20height%3D%2231%22%20x%3D%227%22%20y%3D%2280%22%20fill%3D%22%23ff5e00%22%20class%3D%22spot-loadingglyph__b%20spot-loadingglyph__b3%22%2F%3E%3C%2Fg%3E%3Cpath%20fill%3D%22%23ff5e00%22%20d%3D%22M125.7%20150.18a115%20115%200%200%200-1.73%2039.82H110.1a129%20129%200%200%201%202.23-42.8z%22%20class%3D%22spot-loadingglyph__s%20spot-loadingglyph__s1%22%2F%3E%3Cpath%20fill%3D%22%23ff5e00%22%20d%3D%22M148.14%20103.37a114%20114%200%200%200-22.45%2046.84l-13.38-2.97a129%20129%200%200%201%2025-52.46z%22%20class%3D%22spot-loadingglyph__s%20spot-loadingglyph__s2%22%2F%3E%3Cpath%20fill%3D%22%23ff5e00%22%20d%3D%22M169.37%2082.87a116%20116%200%200%200-21.44%2020.76l-10.84-8.56A129%20129%200%200%201%20159.36%2073z%22%20class%3D%22spot-loadingglyph__s%20spot-loadingglyph__s3%22%2F%3E%3C%2Fsvg%3E From 2c2b483e85a1995f0d093b1c133169b7a54b6e35 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Thu, 22 Jan 2026 16:54:59 +0000 Subject: [PATCH 02/19] loader stacks classic segment --- .../lib/components/loader/loader.less | 40 ++++++++++--------- packages/stacks-docs/_data/loader.json | 19 ++++----- .../product/components/loader.html | 22 ++++------ 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/stacks-classic/lib/components/loader/loader.less b/packages/stacks-classic/lib/components/loader/loader.less index 74209cedd8..2bb46e9988 100644 --- a/packages/stacks-classic/lib/components/loader/loader.less +++ b/packages/stacks-classic/lib/components/loader/loader.less @@ -1,14 +1,16 @@ -.s-loading--block { +.s-loader--block { // BASE COMPONENT-SPECIFIC CUSTOM PROPERTIES --_ld-size: var(--su-static24); --_ld-block-size: calc(var(--su-static4) + var(--su-static1)); --_ld-bg: var(--black-600); --_ld-secondary-bg: var(--black-200); + --_ld-block-shadow-gap: calc((var(--su-static1) + var(--su-static4)) / 2); // MODIFIERS &&__sm { --_ld-size: var(--su-static16); --_ld-block-size: calc((var(--su-static6) + var(--su-static1)) / 2); + --_ld-block-shadow-gap: calc((var(--su-static6) + var(--su-static1)) / 4); } &&__lg { --_ld-size: var(--su-static48); @@ -17,10 +19,10 @@ // CHILD ELEMENTS &:before { - animation: loading-block-animation 1.5s cubic-bezier(1, 1, 0, 1) infinite; + animation: loader-block-animation 1.5s cubic-bezier(1, 1, 0, 1) infinite; background-color: var(--_ld-secondary-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); content: ""; display: block; height: var(--_ld-block-size); @@ -36,45 +38,45 @@ width: var(--_ld-size); } -@keyframes loading-block-animation { +@keyframes loader-block-animation { 0% { background-color: var(--_ld-secondary-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } 16.667% { - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) -5px 0 0 var(--_ld-bg), - calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) -5px 0 0 var(--_ld-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } 33.333% { background-color: var(--_ld-secondary-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } 50% { background-color: var(--_ld-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 5px 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) 5px 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 5px 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 5px 0 0 var(--_ld-secondary-bg); transform: translate(-50%, calc(-50% - 5px)); } 66.667% { background-color: var(--_ld-secondary-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } 83.333% { - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) -5px 0 0 var(--_ld-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) -5px 0 0 var(--_ld-bg); transform: translate(-50%, -50%); } 100% { background-color: var(--_ld-secondary-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - 2.5px) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + 2.5px) 0 0 0 var(--_ld-secondary-bg); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } } \ No newline at end of file diff --git a/packages/stacks-docs/_data/loader.json b/packages/stacks-docs/_data/loader.json index 1e9d30d44a..2381db5a31 100644 --- a/packages/stacks-docs/_data/loader.json +++ b/packages/stacks-docs/_data/loader.json @@ -1,24 +1,19 @@ { - "spinner": [ + "loader": [ { - "class": ".s-spinner", + "class": ".s-loader--block", "applies": "N/A", - "description": "Base loading style that is used almost universally" + "description": "Base block loading style that displays three animated squares" }, { - "class": ".s-spinner__sm", - "applies": ".s-spinner", + "class": ".s-loader--block__sm", + "applies": ".s-loader--block", "description": "A small style for compact layouts" }, { - "class": ".s-spinner__lg", - "applies": ".s-spinner", + "class": ".s-loader--block__lg", + "applies": ".s-loader--block", "description": "A large style for the largest layouts" - }, - { - "class": ".is-loading", - "applies": "Any text-based elements", - "description": "Prepends a spinner pseudo-element to the element. Prefer using the spinner component when possible." } ] } diff --git a/packages/stacks-docs/product/components/loader.html b/packages/stacks-docs/product/components/loader.html index 1fba0508ca..33741d6dec 100644 --- a/packages/stacks-docs/product/components/loader.html +++ b/packages/stacks-docs/product/components/loader.html @@ -1,7 +1,7 @@ --- layout: page title: Loader -svelte: https://beta.svelte.stackoverflow.design/?path=/docs/components-spinner--docs +svelte: https://beta.svelte.stackoverflow.design/?path=/docs/components-loader--docs description: "The Loader indicates an active wait state for a page, section, or interactive element." tags: components --- @@ -32,16 +32,15 @@ {% header "h2", "Examples" %} {% header "h3", "Blocks" %}

Use the Blocks variant as the standard loader for general UI states. This is the most common style and utilizes a monochrome black and gray palette.

-

For accessibility, it’s important to add fallback loading text that is visible to screen readers. Additionally, you should add aria-busy="true" to the component that triggered the loading state while the loader is shown.

{% highlight html %} -
+
Loading…
-
+
Loading…
-
+
Loading…
@@ -49,27 +48,22 @@
{% endhighlight %}
-
+
-
+
Loading…
-
+
Loading…
-
+
Loading…
-
-
- Loading… -
-
From b1e5185a0147d9f43e3e5f87f013888803b54948 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Thu, 22 Jan 2026 18:05:53 +0000 Subject: [PATCH 03/19] svelte component and stories --- .../components/Loader/Loader.stories.svelte | 80 +++++++++++++++++++ .../src/components/Loader/Loader.svelte | 45 +++++++++++ .../Spinner.test.ts => Loader/Loader.test.ts} | 2 +- .../components/Spinner/Spinner.stories.svelte | 34 -------- .../src/components/Spinner/Spinner.svelte | 41 ---------- .../stacks-svelte/src/components/index.ts | 2 +- 6 files changed, 127 insertions(+), 77 deletions(-) create mode 100644 packages/stacks-svelte/src/components/Loader/Loader.stories.svelte create mode 100644 packages/stacks-svelte/src/components/Loader/Loader.svelte rename packages/stacks-svelte/src/components/{Spinner/Spinner.test.ts => Loader/Loader.test.ts} (97%) delete mode 100644 packages/stacks-svelte/src/components/Spinner/Spinner.stories.svelte delete mode 100644 packages/stacks-svelte/src/components/Spinner/Spinner.svelte diff --git a/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte b/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte new file mode 100644 index 0000000000..390f0fd7a1 --- /dev/null +++ b/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte @@ -0,0 +1,80 @@ + + + + + +
+ + + + + + + + + + {#each LoaderSizes as size (size)} + + + + + {/each} + +
SizesExample
+ {size || "default"} + +
+ +
+
+
+
+ + +
+ + + + + + + + + + {#each LoaderVariants as variant (variant)} + + + + + {/each} + +
VariantsExample
+ {variant} + +
+ +
+
+
+
diff --git a/packages/stacks-svelte/src/components/Loader/Loader.svelte b/packages/stacks-svelte/src/components/Loader/Loader.svelte new file mode 100644 index 0000000000..e2f0135dcc --- /dev/null +++ b/packages/stacks-svelte/src/components/Loader/Loader.svelte @@ -0,0 +1,45 @@ + + + + +
+
{label}
+
diff --git a/packages/stacks-svelte/src/components/Spinner/Spinner.test.ts b/packages/stacks-svelte/src/components/Loader/Loader.test.ts similarity index 97% rename from packages/stacks-svelte/src/components/Spinner/Spinner.test.ts rename to packages/stacks-svelte/src/components/Loader/Loader.test.ts index 94f0c30c62..f7ba99aded 100644 --- a/packages/stacks-svelte/src/components/Spinner/Spinner.test.ts +++ b/packages/stacks-svelte/src/components/Loader/Loader.test.ts @@ -2,7 +2,7 @@ import { tick } from "svelte"; import { expect } from "@open-wc/testing"; import { render, screen } from "@testing-library/svelte"; -import Spinner from "./Spinner.svelte"; +import Spinner from "./Loader.svelte"; describe("Spinner", () => { it("should render the spinner at the specified size", async () => { diff --git a/packages/stacks-svelte/src/components/Spinner/Spinner.stories.svelte b/packages/stacks-svelte/src/components/Spinner/Spinner.stories.svelte deleted file mode 100644 index ff718eea70..0000000000 --- a/packages/stacks-svelte/src/components/Spinner/Spinner.stories.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - - -

Sizes

-
- {#each SpinnerSizes as size (size)} -
- -

{size}

-
- {/each} -
-
- - - diff --git a/packages/stacks-svelte/src/components/Spinner/Spinner.svelte b/packages/stacks-svelte/src/components/Spinner/Spinner.svelte deleted file mode 100644 index d4398888d7..0000000000 --- a/packages/stacks-svelte/src/components/Spinner/Spinner.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - - -
-
{label}
-
diff --git a/packages/stacks-svelte/src/components/index.ts b/packages/stacks-svelte/src/components/index.ts index f7afd2095b..00eaf186b8 100644 --- a/packages/stacks-svelte/src/components/index.ts +++ b/packages/stacks-svelte/src/components/index.ts @@ -32,7 +32,7 @@ export { default as RadioGroup } from "./RadioGroup/RadioGroup.svelte"; export { default as Select } from "./Select/Select.svelte"; export { default as SelectItem } from "./Select/SelectItem.svelte"; export { default as Skeleton } from "./Skeleton/Skeleton.svelte"; -export { default as Spinner } from "./Spinner/Spinner.svelte"; +export { default as Loader } from "./Loader/Loader.svelte"; export { default as Tag } from "./Tag/Tag.svelte"; export { default as TextArea } from "./TextArea/TextArea.svelte"; export { default as TextInput } from "./TextInput/TextInput.svelte"; From 80c9e887222a69c7cef65d23d4a099f87f380d74 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Fri, 23 Jan 2026 15:36:52 +0000 Subject: [PATCH 04/19] Update Loader.svelte --- packages/stacks-svelte/src/components/Loader/Loader.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stacks-svelte/src/components/Loader/Loader.svelte b/packages/stacks-svelte/src/components/Loader/Loader.svelte index e2f0135dcc..efa1899e4e 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.svelte +++ b/packages/stacks-svelte/src/components/Loader/Loader.svelte @@ -1,5 +1,5 @@ @@ -23,7 +23,7 @@ const { label = "Loading…", - size = "", + size = undefined, variant = "block", }: Props = $props(); From dbbdf94a1f6647504706b30c9da83ac074a2c650 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Fri, 23 Jan 2026 15:43:52 +0000 Subject: [PATCH 05/19] Update Loader.stories.svelte --- .../stacks-svelte/src/components/Loader/Loader.stories.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte b/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte index 390f0fd7a1..33ffffd4ca 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte +++ b/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte @@ -2,7 +2,7 @@ import { defineMeta } from "@storybook/addon-svelte-csf"; import Loader, { type Size, type Variant } from "./Loader.svelte"; - const LoaderSizes: Size[] = ["", "sm", "lg"]; + const LoaderSizes: Size[] = [undefined, "sm", "lg"]; const LoaderVariants: Variant[] = ["block"]; const { Story } = defineMeta({ From 3abb360a521ae47825b977e295da83838fa2943e Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Fri, 23 Jan 2026 15:43:59 +0000 Subject: [PATCH 06/19] Add tests --- .../src/components/Loader/Loader.test.ts | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/stacks-svelte/src/components/Loader/Loader.test.ts b/packages/stacks-svelte/src/components/Loader/Loader.test.ts index f7ba99aded..fd956c5842 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.test.ts +++ b/packages/stacks-svelte/src/components/Loader/Loader.test.ts @@ -1,44 +1,44 @@ -import { tick } from "svelte"; import { expect } from "@open-wc/testing"; import { render, screen } from "@testing-library/svelte"; -import Spinner from "./Loader.svelte"; +import Loader from "./Loader.svelte"; -describe("Spinner", () => { - it("should render the spinner at the specified size", async () => { - render(Spinner, { - size: "lg", - }); - expect(document.getElementsByClassName("s-spinner")[0]).to.have.class( - "s-spinner__lg" - ); +describe("Loader", () => { + it("should render the loader with the default label", () => { + render(Loader); + expect(screen.getByText("Loading…")).to.exist; }); - it("should render the passed label text", async () => { - render(Spinner, { - label: "Saving…", - }); - expect(screen.getByText("Saving…")).to.exist; + it("should render the loader with a custom label", () => { + render(Loader, { label: "Please wait..." }); + expect(screen.getByText("Please wait...")).to.exist; }); - it("should add the passed classes", async () => { - render(Spinner, { - class: "fc-theme-primary-400", - }); - expect(document.getElementsByClassName("s-spinner")[0]).to.have.class( - "fc-theme-primary-400" - ); + it("should render the loader with the block variant class when variant is provided", () => { + render(Loader, { variant: "block" }); + const loader = screen.getByText("Loading…").closest(".s-loader--block"); + expect(loader).to.exist; }); - it("should adjust the classes on prop updates", async () => { - const { rerender } = render(Spinner, { class: "fc-theme-primary-400" }); - rerender({ class: "fc-theme-secondary-400" }); - await tick(); - expect( - document.getElementsByClassName("s-spinner")[0] - ).not.to.have.class("fc-theme-primary-400"); - expect(document.getElementsByClassName("s-spinner")[0]).to.have.class( - "fc-theme-secondary-400" - ); + it("should render the loader without size modifier class when size is undefined", () => { + render(Loader, { size: undefined }); + const loader = screen.getByText("Loading…").closest(".s-loader--block"); + expect(loader).to.exist; + expect(loader).not.to.have.class("s-loader--block__sm"); + expect(loader).not.to.have.class("s-loader--block__lg"); + }); + + it("should render the loader with the small size class", () => { + render(Loader, { size: "sm" }); + const loader = screen.getByText("Loading…").closest(".s-loader--block"); + expect(loader).to.exist; + expect(loader).to.have.class("s-loader--block__sm"); + }); + + it("should render the loader with the large size class", () => { + render(Loader, { size: "lg" }); + const loader = screen.getByText("Loading…").closest(".s-loader--block"); + expect(loader).to.exist; + expect(loader).to.have.class("s-loader--block__lg"); }); }); From 91d3fa3504d85bf7d15d13d7641f4a67292e1f75 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Fri, 23 Jan 2026 16:28:12 +0000 Subject: [PATCH 07/19] add changeset and update migration guide --- .changeset/nine-gifts-learn.md | 10 ++++++++++ MIGRATION_GUIDE.md | 3 +++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/nine-gifts-learn.md diff --git a/.changeset/nine-gifts-learn.md b/.changeset/nine-gifts-learn.md new file mode 100644 index 0000000000..6e364509a9 --- /dev/null +++ b/.changeset/nine-gifts-learn.md @@ -0,0 +1,10 @@ +--- +"@stackoverflow/stacks": minor +"@stackoverflow/stacks-svelte": minor +--- + +Update Loader (formerly known as Spinner) component to SHINE designs + +BREAKING CHANGES: + +- Spinner component has been replace with the Loader component \ No newline at end of file diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index e58bbe6496..55da23ce4c 100755 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -67,6 +67,9 @@ - `s-input__xl` removed - **Nested inputs** html will require slight tweaking on consumers' side +#### Loader +- `Spinner` component replaced with new `Loader` component + #### Menu The menu component has been updated to use new class names and structure. The following changes are breaking: From 7e88fcb5585c552e4095dfcacb5bc5fb18cef9fa Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Mon, 26 Jan 2026 12:20:05 +0000 Subject: [PATCH 08/19] add loader to button --- .../lib/components/loader/loader.less | 86 +++++++++++++++++-- .../product/components/buttons.html | 83 ++++++++++-------- .../product/components/loader.html | 3 - .../components/Button/Button.stories.svelte | 6 +- .../src/components/Button/Button.svelte | 21 ++--- .../src/components/Button/Button.test.ts | 26 +++++- 6 files changed, 161 insertions(+), 64 deletions(-) diff --git a/packages/stacks-classic/lib/components/loader/loader.less b/packages/stacks-classic/lib/components/loader/loader.less index 2bb46e9988..c19103dd71 100644 --- a/packages/stacks-classic/lib/components/loader/loader.less +++ b/packages/stacks-classic/lib/components/loader/loader.less @@ -2,15 +2,17 @@ // BASE COMPONENT-SPECIFIC CUSTOM PROPERTIES --_ld-size: var(--su-static24); --_ld-block-size: calc(var(--su-static4) + var(--su-static1)); + --_ld-block-shadow-gap: calc((var(--su-static1) + var(--su-static4)) / 2); + --_ld-bounce: var(--_ld-block-size); --_ld-bg: var(--black-600); --_ld-secondary-bg: var(--black-200); - --_ld-block-shadow-gap: calc((var(--su-static1) + var(--su-static4)) / 2); // MODIFIERS &&__sm { --_ld-size: var(--su-static16); - --_ld-block-size: calc((var(--su-static6) + var(--su-static1)) / 2); - --_ld-block-shadow-gap: calc((var(--su-static6) + var(--su-static1)) / 4); + --_ld-block-size: var(--su-static4); + --_ld-block-shadow-gap: var(--su-static2); + --_ld-bounce: var(--_ld-block-size); } &&__lg { --_ld-size: var(--su-static48); @@ -19,7 +21,7 @@ // CHILD ELEMENTS &:before { - animation: loader-block-animation 1.5s cubic-bezier(1, 1, 0, 1) infinite; + animation: loader-block-animation 0.8s cubic-bezier(1, 1, 0, 1) infinite; background-color: var(--_ld-secondary-bg); box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); @@ -46,7 +48,7 @@ transform: translate(-50%, -50%); } 16.667% { - box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) -5px 0 0 var(--_ld-bg), + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) calc(var(--_ld-bounce) * -1) 0 0 var(--_ld-bg), calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } @@ -58,9 +60,9 @@ } 50% { background-color: var(--_ld-bg); - box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 5px 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 5px 0 0 var(--_ld-secondary-bg); - transform: translate(-50%, calc(-50% - 5px)); + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) var(--_ld-bounce) 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) var(--_ld-bounce) 0 0 var(--_ld-secondary-bg); + transform: translate(-50%, calc(-50% - var(--_ld-bounce))); } 66.667% { background-color: var(--_ld-secondary-bg); @@ -70,7 +72,7 @@ } 83.333% { box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), - calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) -5px 0 0 var(--_ld-bg); + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) calc(var(--_ld-bounce) * -1) 0 0 var(--_ld-bg); transform: translate(-50%, -50%); } 100% { @@ -79,4 +81,70 @@ calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); transform: translate(-50%, -50%); } +} + +// When the loader is inside a button, match the loader to the button’s text color so the loader matches the label. +.s-btn .s-loader--block { + --_ld-bg: currentColor; + --_ld-secondary-bg: color-mix(in srgb, currentColor 20%, transparent); + display: block; + flex-shrink: 0; + + &:before { + animation: loader-block-animation-button-sides 0.8s cubic-bezier(1, 1, 0, 1) infinite; + background-color: transparent; + } + + &:after { + animation: loader-block-animation-button-middle 0.8s cubic-bezier(1, 1, 0, 1) infinite; + backface-visibility: hidden; + background-color: var(--_ld-secondary-bg); + content: ""; + display: block; + height: var(--_ld-block-size); + left: 50%; + position: absolute; + top: 50%; + transform: translate3d(-50%, -50%, 0); + will-change: transform; + width: var(--_ld-block-size); + } +} + +@keyframes loader-block-animation-button-sides { + 0%, 100% { + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); + } + 16.667% { + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) calc(var(--_ld-bounce) * -1) 0 0 var(--_ld-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); + } + 33.333%, 66.667% { + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); + } + 50% { + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg); + } + 83.333% { + box-shadow: calc(-1 * var(--_ld-block-size) - var(--_ld-block-shadow-gap)) 0 0 0 var(--_ld-secondary-bg), + calc(var(--_ld-block-size) + var(--_ld-block-shadow-gap)) calc(var(--_ld-bounce) * -1) 0 0 var(--_ld-bg); + } +} + +@keyframes loader-block-animation-button-middle { + 0%, 100% { + background-color: var(--_ld-secondary-bg); + transform: translate3d(-50%, -50%, 0); + } + 16.667%, 33.333%, 66.667%, 83.333% { + background-color: var(--_ld-secondary-bg); + transform: translate3d(-50%, -50%, 0); + } + 50% { + background-color: var(--_ld-bg); + transform: translate3d(-50%, calc(-50% - var(--_ld-bounce)), 0); + } } \ No newline at end of file diff --git a/packages/stacks-docs/product/components/buttons.html b/packages/stacks-docs/product/components/buttons.html index f9f76ebfef..a2fe69129d 100644 --- a/packages/stacks-docs/product/components/buttons.html +++ b/packages/stacks-docs/product/components/buttons.html @@ -268,47 +268,60 @@
{% header "h2", "Loading" %} -

Any button can have a loading state applied by adding the .is-loading state class.

+

Any button can have a loading state applied by adding the .s-loader--block .s-loader--block__sm state class.

{% highlight html %} - + {% endhighlight %}
-
- - +
+ + + + + + + + + + + {% for btn in buttons.variants %} - - - - - + + + + + - - - {% for btn in buttons.variants %} - - - - - - - - {% endfor %} - -
TypeClassDefault StateSelected StateDisabled State
TypeClassDefault StateSelected StateDisabled State{{ btn.title }} +
+ .s-btn + {% if btn.variant != nil %} + .{{ btn.variant }} + {% endif %} + {% if btn.modifier != nil %} + .{{ btn.modifier }} + {% endif %} + .s-loader--block .s-loader--block__sm +
+
{{ btn.title }} -
- .s-btn - {% if btn.variant != nil %} - .{{ btn.variant }} - {% endif %} - {% if btn.modifier != nil %} - .{{ btn.modifier }} - {% endif %} - .is-loading -
-
-
+ {% endfor %} + +
diff --git a/packages/stacks-docs/product/components/loader.html b/packages/stacks-docs/product/components/loader.html index 33741d6dec..cb5a8a4205 100644 --- a/packages/stacks-docs/product/components/loader.html +++ b/packages/stacks-docs/product/components/loader.html @@ -43,9 +43,6 @@
Loading…
-
- Loading… -
{% endhighlight %}
diff --git a/packages/stacks-svelte/src/components/Button/Button.stories.svelte b/packages/stacks-svelte/src/components/Button/Button.stories.svelte index 694f6863c8..8aafcd1e6f 100644 --- a/packages/stacks-svelte/src/components/Button/Button.stories.svelte +++ b/packages/stacks-svelte/src/components/Button/Button.stories.svelte @@ -4,6 +4,7 @@ import type { Brand, Size, Variant, Weight } from "./Button.svelte"; import Icon from "../Icon/Icon.svelte"; import { IconTrash } from "@stackoverflow/stacks-icons/icons"; + import Loader from "../Loader/Loader.svelte"; const ButtonBrands: Brand[] = ["", "facebook", "github", "google"]; const ButtonSizes: Size[] = ["", "xs", "sm", "lg"]; @@ -114,6 +115,9 @@ {#each ButtonVariants as variant (variant)} {#each ButtonWeights as weight (weight)} {#if !(weight === "clear" && (variant === "featured" || variant === "tonal"))} + {#snippet loader()} + + {/snippet} {titleCase(variant || "secondary")} @@ -124,7 +128,7 @@ {#each [null, "selected", "disabled"] as state (state)} +
+ Ask question + {% endhighlight %}
@@ -303,18 +309,21 @@ From fe55c9f533d0012ab0916060bd10c831cae31c76 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Tue, 27 Jan 2026 16:42:22 +0000 Subject: [PATCH 13/19] Update loader.less --- packages/stacks-classic/lib/components/loader/loader.less | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/stacks-classic/lib/components/loader/loader.less b/packages/stacks-classic/lib/components/loader/loader.less index 15d4cc741b..c39109cc46 100644 --- a/packages/stacks-classic/lib/components/loader/loader.less +++ b/packages/stacks-classic/lib/components/loader/loader.less @@ -52,13 +52,9 @@ // Inherit text color when inside a button .s-btn & { color: var(--_bu-fc); - .svg-spot { - color: var(--_bu-fc); - - rect { - fill: currentColor !important; + fill: var(--_bu-fc) !important; } } } From 59e35893305f4b0c500b90ff36533541f03e9381 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Tue, 27 Jan 2026 16:45:54 +0000 Subject: [PATCH 14/19] Update Loader.svelte --- packages/stacks-svelte/src/components/Loader/Loader.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stacks-svelte/src/components/Loader/Loader.svelte b/packages/stacks-svelte/src/components/Loader/Loader.svelte index efa1899e4e..ad8785b5b8 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.svelte +++ b/packages/stacks-svelte/src/components/Loader/Loader.svelte @@ -4,6 +4,9 @@ @@ -102,12 +103,12 @@ dropdown = false, icon = false, link = false, + loading = false, selected = false, unset = false, class: className = "", children, badge, - loader, ...restProps }: Props = $props(); @@ -183,14 +184,14 @@ disabled={(!href && disabled) || null} aria-disabled={href && disabled ? "true" : null} {...restProps} - >{#if loader}{@render loader()}{/if}{#if !badge} - {@render children()} - {:else} + >{#if loading}{/if}{#if !badge}{@render children()}{:else} {@render children()} {@render badge()} - {/if} + {/if} + diff --git a/packages/stacks-svelte/src/components/Button/Button.test.ts b/packages/stacks-svelte/src/components/Button/Button.test.ts index a693a2d3fe..77521cf0b8 100644 --- a/packages/stacks-svelte/src/components/Button/Button.test.ts +++ b/packages/stacks-svelte/src/components/Button/Button.test.ts @@ -130,14 +130,13 @@ describe("Button", () => { expect(screen.getByRole("button")).to.have.class("s-btn__link"); }); - it("should render the loader when loader is provided", () => { - const loaderSnippet = createRawSnippet(() => ({ + it("should render the loading component when loading prop is provided", () => { + const loadingSnippet = createRawSnippet(() => ({ render: () => "", setup: (target) => { const instance = mount(Loader, { target, props: { - variant: "block", size: "sm", }, }); @@ -148,11 +147,11 @@ describe("Button", () => { })); render(Button, { - loader: loaderSnippet, + loading: loadingSnippet, children, }); expect(screen.getByText("Loading…")).to.exist; - expect(screen.getByText("Loading…").closest(".s-loader--block")).to + expect(screen.getByText("Loading…").closest(".s-loader")).to .exist; }); diff --git a/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte b/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte index 33ffffd4ca..67c688a9ca 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte +++ b/packages/stacks-svelte/src/components/Loader/Loader.stories.svelte @@ -1,9 +1,8 @@ @@ -50,31 +45,3 @@
- - -
- - - - - - - - - - {#each LoaderVariants as variant (variant)} - - - - - {/each} - -
VariantsExample
- {variant} - -
- -
-
-
-
diff --git a/packages/stacks-svelte/src/components/Loader/Loader.svelte b/packages/stacks-svelte/src/components/Loader/Loader.svelte index ad8785b5b8..dfb6d880b0 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.svelte +++ b/packages/stacks-svelte/src/components/Loader/Loader.svelte @@ -1,6 +1,5 @@
+
{label}
-
diff --git a/packages/stacks-svelte/src/components/Loader/Loader.test.ts b/packages/stacks-svelte/src/components/Loader/Loader.test.ts index fd956c5842..5eb1e187e6 100644 --- a/packages/stacks-svelte/src/components/Loader/Loader.test.ts +++ b/packages/stacks-svelte/src/components/Loader/Loader.test.ts @@ -14,31 +14,23 @@ describe("Loader", () => { expect(screen.getByText("Please wait...")).to.exist; }); - it("should render the loader with the block variant class when variant is provided", () => { - render(Loader, { variant: "block" }); - const loader = screen.getByText("Loading…").closest(".s-loader--block"); - expect(loader).to.exist; - }); - it("should render the loader without size modifier class when size is undefined", () => { - render(Loader, { size: undefined }); - const loader = screen.getByText("Loading…").closest(".s-loader--block"); + render(Loader); + const loader = screen.getByText("Loading…").closest(".s-loader"); expect(loader).to.exist; - expect(loader).not.to.have.class("s-loader--block__sm"); - expect(loader).not.to.have.class("s-loader--block__lg"); }); it("should render the loader with the small size class", () => { render(Loader, { size: "sm" }); - const loader = screen.getByText("Loading…").closest(".s-loader--block"); + const loader = screen.getByText("Loading…").closest(".s-loader"); expect(loader).to.exist; - expect(loader).to.have.class("s-loader--block__sm"); + expect(loader).to.have.class("s-loader__sm"); }); it("should render the loader with the large size class", () => { render(Loader, { size: "lg" }); - const loader = screen.getByText("Loading…").closest(".s-loader--block"); + const loader = screen.getByText("Loading…").closest(".s-loader"); expect(loader).to.exist; - expect(loader).to.have.class("s-loader--block__lg"); + expect(loader).to.have.class("s-loader__lg"); }); }); From bb83f3d97a5b6dbb3e6044392d8b6eac58078499 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Wed, 28 Jan 2026 16:27:53 +0000 Subject: [PATCH 16/19] update based on comments --- .../product/components/buttons.html | 101 +++++++++--------- .../src/components/Button/Button.svelte | 7 ++ 2 files changed, 58 insertions(+), 50 deletions(-) diff --git a/packages/stacks-docs/product/components/buttons.html b/packages/stacks-docs/product/components/buttons.html index 50fd1096b8..48cbaccbe2 100644 --- a/packages/stacks-docs/product/components/buttons.html +++ b/packages/stacks-docs/product/components/buttons.html @@ -280,58 +280,59 @@ {% endhighlight %}
- - - - - - - - - - - - - {% for btn in buttons.variants %} +
+
TypeClassDefault StateSelected StateDisabled State
+ - - - - - + + + + + - {% endfor %} - -
{{ btn.title }} -
- .s-btn - {% if btn.variant != nil %} - .{{ btn.variant }} - {% endif %} - {% if btn.modifier != nil %} - .{{ btn.modifier }} - {% endif %} - .s-loader--block .s-loader--block__sm -
-
TypeClassDefault StateSelected StateDisabled State
+ + + {% for btn in buttons.variants %} + + {{ btn.title }} + +
+ .s-btn + {% if btn.variant != nil %} + .{{ btn.variant }} + {% endif %} + {% if btn.modifier != nil %} + .{{ btn.modifier }} + {% endif %} + .s-loader s-loader__sm +
+ + + + + + {% endfor %} + + +
diff --git a/packages/stacks-svelte/src/components/Button/Button.svelte b/packages/stacks-svelte/src/components/Button/Button.svelte index 8ebed227f9..dd264e98ae 100644 --- a/packages/stacks-svelte/src/components/Button/Button.svelte +++ b/packages/stacks-svelte/src/components/Button/Button.svelte @@ -65,6 +65,11 @@ */ loading?: boolean; + /** + * The i18n loading text + */ + i18nLoadingText?: string; + /** * Modifier describing if the button is selected */ @@ -104,6 +109,7 @@ icon = false, link = false, loading = false, + i18nLoadingText = "Loading…", selected = false, unset = false, class: className = "", @@ -186,6 +192,7 @@ {...restProps} >{#if loading}{/if}{#if !badge}{@render children()}{:else} {@render children()} From 45828087d0ff4eacf4c2da79e4f95e749d8fcac9 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Wed, 28 Jan 2026 16:41:19 +0000 Subject: [PATCH 17/19] Update site-navigation.json --- packages/stacks-docs/_data/site-navigation.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stacks-docs/_data/site-navigation.json b/packages/stacks-docs/_data/site-navigation.json index ccc32fe7ec..112abaabfd 100644 --- a/packages/stacks-docs/_data/site-navigation.json +++ b/packages/stacks-docs/_data/site-navigation.json @@ -287,6 +287,10 @@ "title": "Link previews", "url": "/product/components/link-previews/" }, + { + "title": "Loader", + "url": "/product/components/loader/" + }, { "title": "Menus", "url": "/product/components/menus/", @@ -337,10 +341,6 @@ "title": "Sidebar widgets", "url": "/product/components/sidebar-widgets/" }, - { - "title": "Spinner", - "url": "/product/components/spinner/" - }, { "title": "Tables", "url": "/product/components/tables/" From 0a9fae08ca8f3e8ef31b6f60c96ab639e75333d4 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Wed, 28 Jan 2026 16:47:05 +0000 Subject: [PATCH 18/19] fix test --- .../src/components/Button/Button.test.ts | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/packages/stacks-svelte/src/components/Button/Button.test.ts b/packages/stacks-svelte/src/components/Button/Button.test.ts index 77521cf0b8..3bbd37717b 100644 --- a/packages/stacks-svelte/src/components/Button/Button.test.ts +++ b/packages/stacks-svelte/src/components/Button/Button.test.ts @@ -1,11 +1,10 @@ -import { createRawSnippet, mount, unmount, tick } from "svelte"; +import { createRawSnippet, tick } from "svelte"; import { expect } from "@open-wc/testing"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import sinon from "sinon"; import Button from "./Button.svelte"; -import Loader from "../Loader/Loader.svelte"; const children = createRawSnippet(() => ({ render: () => "test btn", @@ -131,28 +130,12 @@ describe("Button", () => { }); it("should render the loading component when loading prop is provided", () => { - const loadingSnippet = createRawSnippet(() => ({ - render: () => "", - setup: (target) => { - const instance = mount(Loader, { - target, - props: { - size: "sm", - }, - }); - return () => { - unmount(instance); - }; - }, - })); - render(Button, { - loading: loadingSnippet, + loading: true, children, }); expect(screen.getByText("Loading…")).to.exist; - expect(screen.getByText("Loading…").closest(".s-loader")).to - .exist; + expect(screen.getByText("Loading…").closest(".s-loader")).to.exist; }); it("should render including the selected class", () => { From c0e8da83a8d2321f87ef2c16f6be063b488decb7 Mon Sep 17 00:00:00 2001 From: Tavian Taylor Date: Wed, 28 Jan 2026 16:47:18 +0000 Subject: [PATCH 19/19] Update Button.test.ts --- packages/stacks-svelte/src/components/Button/Button.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stacks-svelte/src/components/Button/Button.test.ts b/packages/stacks-svelte/src/components/Button/Button.test.ts index 3bbd37717b..f426eac00e 100644 --- a/packages/stacks-svelte/src/components/Button/Button.test.ts +++ b/packages/stacks-svelte/src/components/Button/Button.test.ts @@ -129,7 +129,7 @@ describe("Button", () => { expect(screen.getByRole("button")).to.have.class("s-btn__link"); }); - it("should render the loading component when loading prop is provided", () => { + it("should render the loader component when loading prop is provided", () => { render(Button, { loading: true, children,