diff --git a/libs/react/ui/src/components/button/button.stories.tsx b/libs/react/ui/src/components/button/button.stories.tsx
index 7404a927..a3739723 100644
--- a/libs/react/ui/src/components/button/button.stories.tsx
+++ b/libs/react/ui/src/components/button/button.stories.tsx
@@ -26,6 +26,7 @@ const meta = {
options: sizeOptions,
},
asChild: {control: 'boolean'},
+ isLoading: {control: 'boolean'},
},
args: {
children: 'Click me',
@@ -118,3 +119,20 @@ export const Icons: Story = {
),
};
+
+export const Loading: Story = {
+ render: (args) => (
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/libs/react/ui/src/components/button/button.tsx b/libs/react/ui/src/components/button/button.tsx
index ee411912..7c661988 100644
--- a/libs/react/ui/src/components/button/button.tsx
+++ b/libs/react/ui/src/components/button/button.tsx
@@ -38,6 +38,15 @@ export const buttonVariants = cva(
},
);
+const spinnerSizeMap: Record['size']>, string> = {
+ '2xs': 'size-10',
+ xs: 'size-10',
+ sm: 'size-12',
+ md: 'size-14',
+ lg: 'size-16',
+ xl: 'size-18',
+};
+
export function Button({
className,
variant,
@@ -46,18 +55,34 @@ export function Button({
children,
iconLeft,
iconRight,
+ isLoading = false,
+ disabled,
...props
}: ComponentProps<'button'> &
VariantProps & {
asChild?: boolean;
iconLeft?: IconName;
iconRight?: IconName;
+ isLoading?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
+ const spinnerSize =
+ spinnerSizeMap[(size ?? 'md') as NonNullable['size']>];
return (
-
- {iconLeft && }
+
+ {isLoading ? (
+
+ ) : (
+ iconLeft &&
+ )}
{children}
{iconRight && }
diff --git a/libs/react/ui/src/components/button/icon-button.stories.tsx b/libs/react/ui/src/components/button/icon-button.stories.tsx
index 7cf12613..8f7ee495 100644
--- a/libs/react/ui/src/components/button/icon-button.stories.tsx
+++ b/libs/react/ui/src/components/button/icon-button.stories.tsx
@@ -180,3 +180,49 @@ export const Sizes: Story = {
),
};
+
+export const Loading: Story = {
+ render: ({children: _children, ...args}) => (
+
+
+
Loading by Size:
+
+ {sizeOptions.map((size) => (
+
+
+ {size}
+
+
+
+ ))}
+
+
+
+
Loading by Variant:
+
+ {variantOptions.map((variant) => (
+
+
+ {variant}
+
+
+
+ ))}
+
+
+
+
Normal vs Loading:
+
+
+
+
+
+
+ ),
+};
diff --git a/libs/react/ui/src/components/button/icon-button.tsx b/libs/react/ui/src/components/button/icon-button.tsx
index 3e2f00cb..deb75eb7 100644
--- a/libs/react/ui/src/components/button/icon-button.tsx
+++ b/libs/react/ui/src/components/button/icon-button.tsx
@@ -40,6 +40,18 @@ export const iconButtonVariants = cva(
},
);
+const spinnerSizeMap: Record<
+ NonNullable['size']>,
+ string
+> = {
+ '2xs': 'size-8',
+ xs: 'size-10',
+ sm: 'size-12',
+ md: 'size-14',
+ lg: 'size-16',
+ xl: 'size-18',
+};
+
export function IconButton({
className,
variant,
@@ -49,21 +61,34 @@ export function IconButton({
asChild = false,
children,
icon,
+ isLoading = false,
+ disabled,
...props
}: ComponentProps<'button'> &
VariantProps & {
asChild?: boolean;
icon?: IconName;
+ isLoading?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
+ const spinnerSize = spinnerSizeMap[size ?? 'md'];
return (
- {icon ? : children}
+ {isLoading ? (
+
+ ) : icon ? (
+
+ ) : (
+ children
+ )}
);
}
diff --git a/libs/react/ui/src/components/icon/custom/spinner.tsx b/libs/react/ui/src/components/icon/custom/spinner.tsx
index b9c9f870..e500602e 100644
--- a/libs/react/ui/src/components/icon/custom/spinner.tsx
+++ b/libs/react/ui/src/components/icon/custom/spinner.tsx
@@ -1,12 +1,56 @@
import type {RemixiconComponentType} from '@remixicon/react';
+import {motion, type SVGMotionProps, type Variants} from 'framer-motion';
import type {ComponentProps} from 'react';
+import {cn} from 'utils/cn';
-export function SpinnerIcon(_props: ComponentProps) {
+const SEGMENT_COUNT = 8;
+const DURATION = 1.2;
+const BASE_OPACITY = 0;
+
+const CLOCKWISE_ORDER = [1, 8, 4, 6, 2, 7, 3, 5];
+
+const segmentVariants: Record = {};
+
+for (let i = 0; i < SEGMENT_COUNT; i++) {
+ const segmentIndex = CLOCKWISE_ORDER[i];
+ const delay = (i * DURATION) / SEGMENT_COUNT;
+
+ segmentVariants[`segment${segmentIndex}`] = {
+ initial: {opacity: BASE_OPACITY},
+ animate: {
+ opacity: [BASE_OPACITY, 1, BASE_OPACITY],
+ transition: {
+ duration: DURATION,
+ ease: 'easeInOut',
+ repeat: Infinity,
+ repeatType: 'loop',
+ delay,
+ times: [0, 0.5, 1],
+ },
+ },
+ };
+}
+
+export function SpinnerIcon(props: ComponentProps) {
+ const {className, size, ...restProps} = props;
+
+ const iconSize = size ?? 24;
+
+ const svgProps: SVGMotionProps = {
+ width: typeof iconSize === 'number' ? String(iconSize) : iconSize,
+ height: typeof iconSize === 'number' ? String(iconSize) : iconSize,
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ xmlns: 'http://www.w3.org/2000/svg',
+ className: cn(className),
+ initial: 'initial',
+ animate: 'animate',
+ ...(restProps as SVGMotionProps),
+ };
return (
-
+
);
}
diff --git a/libs/react/ui/src/components/icon/icon.tsx b/libs/react/ui/src/components/icon/icon.tsx
index ee66db60..9fe15a2a 100644
--- a/libs/react/ui/src/components/icon/icon.tsx
+++ b/libs/react/ui/src/components/icon/icon.tsx
@@ -1,5 +1,5 @@
+import type {RemixiconComponentType} from '@remixicon/react';
import {
- type RemixiconComponentType,
RiAddLine,
RiArrowRightSLine,
RiBookOpenFill,
@@ -36,32 +36,32 @@ import {
const iconsMap = {
google: RiGoogleFill,
microsoft: RiMicrosoftFill,
+ github: RiGithubFill,
+ shipfox: ShipfoxLogo,
+ slack: SlackLogo,
+ stripe: StripeLogo,
badge: BadgeIcon,
checkCircleSolid: CheckCircleSolidIcon,
circleDottedLine: CircleDottedLineIcon,
componentFill: ComponentFillIcon,
- xCircleSolid: XCircleSolidIcon,
- thunder: ThunderIcon,
- resize: ResizeIcon,
+ componentLine: ComponentLineIcon,
+ ellipseMiniSolid: EllipseMiniSolidIcon,
infoTooltipFill: InfoTooltipFillIcon,
+ resize: ResizeIcon,
spinner: SpinnerIcon,
- ellipseMiniSolid: EllipseMiniSolidIcon,
- componentLine: ComponentLineIcon,
- imageAdd: RiImageAddFill,
- close: RiCloseLine,
- shipfox: ShipfoxLogo,
- slack: SlackLogo,
- stripe: StripeLogo,
- github: RiGithubFill,
+ thunder: ThunderIcon,
+ xCircleSolid: XCircleSolidIcon,
+ addLine: RiAddLine,
+ bookOpen: RiBookOpenFill,
check: RiCheckLine,
- subtractLine: RiSubtractLine,
+ chevronRight: RiArrowRightSLine,
+ close: RiCloseLine,
+ copy: RiFileCopyLine,
+ homeSmile: RiHomeSmileFill,
+ imageAdd: RiImageAddFill,
info: RiInformationFill,
money: RiMoneyDollarCircleLine,
- homeSmile: RiHomeSmileFill,
- copy: RiFileCopyLine,
- addLine: RiAddLine,
- chevronRight: RiArrowRightSLine,
- bookOpen: RiBookOpenFill,
+ subtractLine: RiSubtractLine,
} as const satisfies Record;
export type IconName = keyof typeof iconsMap;