feat(spinner): new spinner variants (#4555)

* refactor(spinner): add default variant

* feature(spinner): add gradient variant

* feature(spinner): add dots variant

* feature(spinner): add dots-blink variant

* feature(spinner): add spinner-bars

* chore(spinner): add variants storybook

* chore: adding variants to docs

* chore: simplyfying the styles and modifying docs

* chore: nits

* chore: updating the dots and dots-blink animation

* chore: nits

* chore: adding Marcus' suggestions

* chore: adding Marcus's suggestions

* chore: adding junior's suggestions

---------

Co-authored-by: Maharshi Alpesh <maharshialpesh@gmail.com>
This commit is contained in:
Peterl561 2025-01-30 22:22:48 +08:00 committed by GitHub
parent a66476d60c
commit 1965b8406a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 326 additions and 34 deletions

View File

@ -0,0 +1,6 @@
---
"@heroui/spinner": patch
"@heroui/theme": patch
---
Adding variants to the Spinner Component.

View File

@ -3,6 +3,7 @@ import sizes from "./sizes";
import colors from "./colors";
import label from "./label";
import labelColors from "./label-colors";
import variants from "./variants";
export const spinnerContent = {
usage,
@ -10,4 +11,5 @@ export const spinnerContent = {
colors,
label,
labelColors,
variants,
};

View File

@ -0,0 +1,13 @@
import {Spinner} from "@heroui/react";
export default function App() {
return (
<div className="flex flex-wrap items-end gap-8">
<Spinner classNames={{label: "text-primary-400 mt-4"}} label="default" variant="default" />
<Spinner classNames={{label: "text-primary-400 mt-4"}} label="gradient" variant="gradient" />
<Spinner classNames={{label: "text-primary-400 mt-4"}} label="spinner" variant="spinner" />
<Spinner classNames={{label: "text-primary-400 mt-4"}} label="wave" variant="wave" />
<Spinner classNames={{label: "text-primary-400 mt-4"}} label="dots" variant="dots" />
</div>
);
}

View File

@ -0,0 +1,9 @@
import App from "./variants.raw.jsx?raw";
const react = {
"/App.jsx": App,
};
export default {
...react,
};

View File

@ -197,6 +197,15 @@ The available options are:
- **Type**: `"user" | "always" | "never"`
- **Default**: `"never"`
`spinnerVariant`
- **Description**: The default variant of the spinner.
- **Type**: `string` | `undefined`
- **Possible Values**: `default` | `gradient` | `wave` | `dots` | `spinner`
- **Default**: `default`
<Spacer y={2}/>
---
## Types

View File

@ -60,12 +60,18 @@ Spinner express an unspecified wait time or display the length of a process.
<CodeDemo title="Label colors" files={spinnerContent.labelColors} />
### Variants
<CodeDemo title="Variants" files={spinnerContent.variants} />
## Slots
- **base**: The base slot of the spinner, it wraps the circles and the label.
- **wrapper**: The wrapper of the circles.
- **circle1**: The first circle of the spinner.
- **circle2**: The second circle of the spinner.
- **circle1**: The first circle of the spinner component. (Effective only when variant is `default` or `gradient`)
- **circle2**: The second circle of the spinner component. (Effective only when variant is `default` or `gradient`)
- **dots**: Dots of the spinner component. (Effective only when variant is `wave` or `dots`)
- **spinnerBars**: Bars of the spinner component. (Effective only when variant is `spinner`)
- **label**: The label content.
<Spacer y={4} />
@ -94,6 +100,12 @@ Spinner express an unspecified wait time or display the length of a process.
description: "The color of the spinner circles.",
default: "primary"
},
{
attribute: "variant",
type: "default | gradient | wave | dots | spinner",
description: "The variant of the spinner",
default: "default"
},
{
attribute: "labelColor",
type: "default | primary | secondary | success | warning | danger",
@ -102,7 +114,7 @@ Spinner express an unspecified wait time or display the length of a process.
},
{
attribute: "classNames",
type: "Partial<Record<\"base\"\"wrapper\"\"circle1\"\"circle2\"\"label\", string>>",
type: "Partial<Record<'base' | 'wrapper' | 'circle1' | 'circle2' | 'dots' | 'spinnerBars' | 'label', string>>",
description: "Allows to set custom class names for the spinner slots.",
default: "-"
}

View File

@ -38,12 +38,13 @@
"peerDependencies": {
"react": ">=18 || >=19.0.0-rc.0",
"react-dom": ">=18 || >=19.0.0-rc.0",
"@heroui/theme": ">=2.4.0"
"@heroui/theme": ">=2.4.7"
},
"dependencies": {
"@heroui/system-rsc": "workspace:*",
"@heroui/shared-utils": "workspace:*",
"@heroui/react-utils": "workspace:*"
"@heroui/react-utils": "workspace:*",
"@heroui/system": "workspace:*"
},
"devDependencies": {
"@heroui/theme": "workspace:*",

View File

@ -5,7 +5,49 @@ import {UseSpinnerProps, useSpinner} from "./use-spinner";
export interface SpinnerProps extends UseSpinnerProps {}
const Spinner = forwardRef<"div", SpinnerProps>((props, ref) => {
const {slots, classNames, label, getSpinnerProps} = useSpinner({...props});
const {slots, classNames, label, variant, getSpinnerProps} = useSpinner({...props});
if (variant === "wave" || variant === "dots") {
return (
<div ref={ref} {...getSpinnerProps()}>
<div className={slots.wrapper({class: classNames?.wrapper})}>
{[...new Array(3)].map((_, index) => (
<i
key={`dot-${index}`}
className={slots.dots({class: classNames?.dots})}
style={
{
"--dot-index": index,
} as React.CSSProperties
}
/>
))}
</div>
{label && <span className={slots.label({class: classNames?.label})}>{label}</span>}
</div>
);
}
if (variant === "spinner") {
return (
<div ref={ref} {...getSpinnerProps()}>
<div className={slots.wrapper({class: classNames?.wrapper})}>
{[...new Array(12)].map((_, index) => (
<i
key={`star-${index}`}
className={slots.spinnerBars({class: classNames?.spinnerBars})}
style={
{
"--bar-index": index,
} as React.CSSProperties
}
/>
))}
</div>
{label && <span className={slots.label({class: classNames?.label})}>{label}</span>}
</div>
);
}
return (
<div ref={ref} {...getSpinnerProps()}>

View File

@ -5,6 +5,7 @@ import {mapPropsVariants} from "@heroui/system-rsc";
import {spinner} from "@heroui/theme";
import {clsx, objectToDeps} from "@heroui/shared-utils";
import {useMemo, useCallback, Ref} from "react";
import {useProviderContext} from "@heroui/system";
interface Props extends HTMLHeroUIProps<"div"> {
/**
@ -38,6 +39,9 @@ export type UseSpinnerProps = Props & SpinnerVariantProps;
export function useSpinner(originalProps: UseSpinnerProps) {
const [props, variantProps] = mapPropsVariants(originalProps, spinner.variantKeys);
const globalContext = useProviderContext();
const variant = originalProps?.variant ?? globalContext?.spinnerVariant ?? "default";
const {children, className, classNames, label: labelProp, ...otherProps} = props;
const slots = useMemo(() => spinner({...variantProps}), [objectToDeps(variantProps)]);
@ -65,7 +69,7 @@ export function useSpinner(originalProps: UseSpinnerProps) {
[ariaLabel, slots, baseStyles, otherProps],
);
return {label, slots, classNames, getSpinnerProps};
return {label, slots, classNames, variant, getSpinnerProps};
}
export type UseSpinnerReturn = ReturnType<typeof useSpinner>;

View File

@ -2,7 +2,7 @@ import React from "react";
import {Meta} from "@storybook/react";
import {spinner} from "@heroui/theme";
import {Spinner} from "../src";
import {Spinner, SpinnerProps} from "../src";
export default {
title: "Components/Spinner",
@ -26,6 +26,12 @@ export default {
},
options: ["sm", "md", "lg"],
},
variant: {
control: {
type: "select",
},
options: ["default", "gradient", "spinner", "wave", "dots"],
},
},
decorators: [
(Story) => (
@ -40,6 +46,18 @@ const defaultProps = {
...spinner.defaultVariants,
};
const VariantsTemplate = (args: SpinnerProps) => {
return (
<div className="flex flex-wrap items-end gap-8 py-4">
<Spinner {...args} label="default" variant="default" />
<Spinner {...args} label="gradient" variant="gradient" />
<Spinner {...args} label="spinner" variant="spinner" />
<Spinner {...args} label="wave" variant="wave" />
<Spinner {...args} label="dots" variant="dots" />
</div>
);
};
export const Default = {
args: {
...defaultProps,
@ -52,3 +70,14 @@ export const WithLabel = {
label: "Loading...",
},
};
export const Variants = {
args: {
...defaultProps,
classNames: {
label: "text-primary-400 mt-4",
},
},
render: VariantsTemplate,
};

View File

@ -1,4 +1,4 @@
import type {SupportedCalendars} from "./types";
import type {SpinnerVariants, SupportedCalendars} from "./types";
import type {Calendar} from "@internationalized/date";
import type {DateValue} from "@react-types/datepicker";
@ -87,6 +87,11 @@ export type ProviderContextProps = {
* @default all calendars
*/
createCalendar?: (calendar: SupportedCalendars) => Calendar | null;
/**
* The default variant of the spinner.
* @default default
*/
spinnerVariant?: SpinnerVariants;
};
export const [ProviderContext, useProviderContext] = createContext<ProviderContextProps>({

View File

@ -63,6 +63,7 @@ export const HeroUIProvider: React.FC<HeroUIProviderProps> = ({
// then they will be set in `use-date-input.ts` or `use-calendar-base.ts`
defaultDates,
createCalendar,
spinnerVariant,
...otherProps
}) => {
let contents = children;
@ -87,6 +88,7 @@ export const HeroUIProvider: React.FC<HeroUIProviderProps> = ({
disableRipple,
validationBehavior,
labelPlacement,
spinnerVariant,
};
}, [
createCalendar,
@ -96,6 +98,7 @@ export const HeroUIProvider: React.FC<HeroUIProviderProps> = ({
disableRipple,
validationBehavior,
labelPlacement,
spinnerVariant,
]);
return (

View File

@ -15,3 +15,8 @@ export type SupportedCalendars =
| "persian"
| "roc"
| "gregory";
/**
* Spinner Variants
*/
export type SpinnerVariants = "default" | "gradient" | "wave" | "dots" | "spinner";

View File

@ -3,6 +3,9 @@ export const animations = {
"drip-expand": "drip-expand 420ms linear",
"spinner-ease-spin": "spinner-spin 0.8s ease infinite",
"spinner-linear-spin": "spinner-spin 0.8s linear infinite",
sway: "sway 750ms ease infinite",
blink: "blink 1.4s infinite both",
"fade-out": "fade-out 1.2s linear 0s infinite normal none running",
"appearance-in": "appearance-in 250ms ease-out normal both",
"appearance-out": "appearance-out 60ms ease-in normal both",
"indeterminate-bar":
@ -67,5 +70,35 @@ export const animations = {
transform: "translateX(100%) scaleX(1)",
},
},
sway: {
"0%": {
transform: "translate(0px, 0px)",
},
"50%": {
transform: "translate(0px, -150%)",
},
"100%": {
transform: "translate(0px, 0px)",
},
},
blink: {
"0%": {
opacity: "0.2",
},
"20%": {
opacity: "1",
},
"100%": {
opacity: "0.2",
},
},
"fade-out": {
"0%": {
opacity: "1",
},
"100%": {
opacity: "0.15",
},
},
},
};

View File

@ -18,32 +18,20 @@ const spinner = tv({
slots: {
base: "relative inline-flex flex-col gap-2 items-center justify-center",
wrapper: "relative flex",
circle1: [
"absolute",
"w-full",
"h-full",
"rounded-full",
"animate-spinner-ease-spin",
"border-2",
"border-solid",
"border-t-transparent",
"border-l-transparent",
"border-r-transparent",
],
circle2: [
"absolute",
"w-full",
"h-full",
"rounded-full",
"opacity-75",
"animate-spinner-linear-spin",
"border-2",
"border-dotted",
"border-t-transparent",
"border-l-transparent",
"border-r-transparent",
],
label: "text-foreground dark:text-foreground-dark font-regular",
circle1: "absolute w-full h-full rounded-full",
circle2: "absolute w-full h-full rounded-full",
dots: "relative rounded-full mx-auto",
spinnerBars: [
"absolute",
"animate-fade-out",
"rounded-full",
"w-[25%]",
"h-[8%]",
"left-[calc(37.5%)]",
"top-[calc(46%)]",
"spinner-bar-animation",
],
},
variants: {
size: {
@ -51,18 +39,21 @@ const spinner = tv({
wrapper: "w-5 h-5",
circle1: "border-2",
circle2: "border-2",
dots: "size-1",
label: "text-small",
},
md: {
wrapper: "w-8 h-8",
circle1: "border-3",
circle2: "border-3",
dots: "size-1.5",
label: "text-medium",
},
lg: {
wrapper: "w-10 h-10",
circle1: "border-3",
circle2: "border-3",
dots: "size-2",
label: "text-large",
},
},
@ -70,34 +61,50 @@ const spinner = tv({
current: {
circle1: "border-b-current",
circle2: "border-b-current",
dots: "bg-current",
spinnerBars: "bg-current",
},
white: {
circle1: "border-b-white",
circle2: "border-b-white",
dots: "bg-white",
spinnerBars: "bg-white",
},
default: {
circle1: "border-b-default",
circle2: "border-b-default",
dots: "bg-default",
spinnerBars: "bg-default",
},
primary: {
circle1: "border-b-primary",
circle2: "border-b-primary",
dots: "bg-primary",
spinnerBars: "bg-primary",
},
secondary: {
circle1: "border-b-secondary",
circle2: "border-b-secondary",
dots: "bg-secondary",
spinnerBars: "bg-secondary",
},
success: {
circle1: "border-b-success",
circle2: "border-b-success",
dots: "bg-success",
spinnerBars: "bg-success",
},
warning: {
circle1: "border-b-warning",
circle2: "border-b-warning",
dots: "bg-warning",
spinnerBars: "bg-warning",
},
danger: {
circle1: "border-b-danger",
circle2: "border-b-danger",
dots: "bg-danger",
spinnerBars: "bg-danger",
},
},
labelColor: {
@ -120,12 +127,106 @@ const spinner = tv({
label: "text-danger",
},
},
variant: {
default: {
circle1: [
"animate-spinner-ease-spin",
"border-solid",
"border-t-transparent",
"border-l-transparent",
"border-r-transparent",
],
circle2: [
"opacity-75",
"animate-spinner-linear-spin",
"border-dotted",
"border-t-transparent",
"border-l-transparent",
"border-r-transparent",
],
},
gradient: {
circle1: [
"border-0",
"bg-gradient-to-b",
"from-transparent",
"via-transparent",
"to-primary",
"animate-spinner-linear-spin",
"[animation-duration:1s]",
"[-webkit-mask:radial-gradient(closest-side,rgba(0,0,0,0.0)calc(100%-3px),rgba(0,0,0,1)calc(100%-3px))]",
],
circle2: ["hidden"],
},
wave: {
wrapper: "translate-y-3/4",
dots: ["animate-sway", "spinner-dot-animation"],
},
dots: {
wrapper: "translate-y-2/4",
dots: ["animate-blink", "spinner-dot-blink-animation"],
},
spinner: {},
},
},
defaultVariants: {
size: "md",
color: "primary",
labelColor: "foreground",
variant: "default",
},
compoundVariants: [
{variant: "gradient", color: "current", class: {circle1: "to-current"}},
{variant: "gradient", color: "white", class: {circle1: "to-white"}},
{variant: "gradient", color: "default", class: {circle1: "to-default"}},
{variant: "gradient", color: "primary", class: {circle1: "to-primary"}},
{variant: "gradient", color: "secondary", class: {circle1: "to-secondary"}},
{variant: "gradient", color: "success", class: {circle1: "to-success"}},
{variant: "gradient", color: "warning", class: {circle1: "to-warning"}},
{variant: "gradient", color: "danger", class: {circle1: "to-danger"}},
{
variant: "wave",
size: "sm",
class: {
wrapper: "w-5 h-5",
},
},
{
variant: "wave",
size: "md",
class: {
wrapper: "w-8 h-8",
},
},
{
variant: "wave",
size: "lg",
class: {
wrapper: "w-12 h-12",
},
},
{
variant: "dots",
size: "sm",
class: {
wrapper: "w-5 h-5",
},
},
{
variant: "dots",
size: "md",
class: {
wrapper: "w-8 h-8",
},
},
{
variant: "dots",
size: "lg",
class: {
wrapper: "w-12 h-12",
},
},
],
});
export type SpinnerVariantProps = VariantProps<typeof spinner>;

View File

@ -0,0 +1,13 @@
export default {
/** Animation Utilities */
".spinner-bar-animation": {
"animation-delay": "calc(-1.2s + (0.1s * var(--bar-index)))",
transform: "rotate(calc(30deg * var(--bar-index)))translate(140%)",
},
".spinner-dot-animation": {
"animation-delay": "calc(250ms * var(--dot-index))",
},
".spinner-dot-blink-animation": {
"animation-delay": "calc(200ms * var(--dot-index))",
},
};

View File

@ -1,9 +1,11 @@
import transition from "./transition";
import custom from "./custom";
import scrollbarHide from "./scrollbar-hide";
import animation from "./animation";
export const utilities = {
...custom,
...transition,
...scrollbarHide,
...animation,
};

3
pnpm-lock.yaml generated
View File

@ -2785,6 +2785,9 @@ importers:
'@heroui/shared-utils':
specifier: workspace:*
version: link:../../utilities/shared-utils
'@heroui/system':
specifier: workspace:*
version: link:../../core/system
'@heroui/system-rsc':
specifier: workspace:*
version: link:../../core/system-rsc