Skip to content

Commit bfedac4

Browse files
Merge pull request #671 from aXenDeveloper/button_motion
feat(button): ✨ Add loading state to Button with motion
2 parents 2dc5501 + dc069a0 commit bfedac4

File tree

6 files changed

+86
-52
lines changed

6 files changed

+86
-52
lines changed

apps/docs/src/examples/button.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,48 @@
1+
'use client';
2+
13
import { Button } from '@vitnode/core/components/ui/button';
24
import { Card } from '@vitnode/core/components/ui/card';
35
import { ArrowRight, CheckCircle, Eye, Home, Star, Trash2 } from 'lucide-react';
6+
import React from 'react';
47

58
export default function ButtonExample() {
9+
const [isLoading, setIsLoading] = React.useState(false);
10+
611
return (
712
<Card className="flex flex-row flex-wrap items-center justify-center gap-6 p-8">
8-
<Button size="lg">
13+
<Button isLoading={isLoading} size="lg">
914
<Home />
1015
Default
1116
</Button>
12-
<Button variant="secondary">
17+
<Button isLoading={isLoading} variant="secondary">
1318
<Star />
1419
Secondary
1520
</Button>
16-
<Button variant="outline">
21+
<Button isLoading={isLoading} variant="outline">
1722
<Eye />
1823
Outline
1924
</Button>
20-
<Button variant="ghost">
25+
<Button isLoading={isLoading} variant="ghost">
2126
<CheckCircle />
2227
Ghost
2328
</Button>
24-
<Button variant="link">
29+
<Button isLoading={isLoading} variant="link">
2530
<ArrowRight />
2631
Link
2732
</Button>
28-
<Button size="sm" variant="destructive">
33+
<Button isLoading={isLoading} size="sm" variant="destructive">
2934
<Trash2 />
3035
Destructive
3136
</Button>
32-
<Button aria-label="Delete" size="icon" variant="destructiveGhost">
37+
<Button
38+
aria-label="Delete"
39+
isLoading={isLoading}
40+
size="icon"
41+
variant="destructiveGhost"
42+
>
3343
<Trash2 />
3444
</Button>
45+
<Button onClick={() => setIsLoading(!isLoading)}>Toggle Loading</Button>
3546
</Card>
3647
);
3748
}

packages/vitnode/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"react": "19.1.x",
3333
"react-dom": "19.1.x",
3434
"react-hook-form": "^7.x.x",
35+
"motion": "^12.x.x",
3536
"typescript": "^5.8.x",
3637
"zod": "4.x.x"
3738
},
@@ -115,6 +116,7 @@
115116
"clsx": "^2.1.1",
116117
"cmdk": "^1.1.1",
117118
"input-otp": "^1.4.2",
119+
"motion": "^12.23.6",
118120
"next-themes": "^0.4.6",
119121
"nodemailer": "^7.0.5",
120122
"postgres": "^3.4.7",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client';
2+
3+
import { AnimatePresence, motion } from 'motion/react';
4+
import { useTranslations } from 'next-intl';
5+
import { Slot } from 'radix-ui';
6+
7+
import { cn } from '../../lib/utils';
8+
import { type ButtonProps, buttonVariants } from './button';
9+
import { Loader } from './loader';
10+
11+
export function ClientButton({
12+
className,
13+
variant,
14+
size,
15+
asChild = false,
16+
isLoading,
17+
children,
18+
...props
19+
}: ButtonProps) {
20+
const Comp = asChild ? Slot.Root : 'button';
21+
const t = useTranslations('core.global');
22+
23+
return (
24+
<Comp
25+
aria-label={isLoading ? t('loading') : props['aria-label']}
26+
className={cn(buttonVariants({ variant, size, className }))}
27+
data-slot="button"
28+
disabled={isLoading ?? props.disabled}
29+
{...props}
30+
>
31+
<div className="relative flex items-center justify-center">
32+
<div
33+
className={cn(
34+
'flex items-center justify-center gap-2 transition-opacity duration-300',
35+
isLoading ? 'opacity-0' : 'opacity-100',
36+
)}
37+
>
38+
{children}
39+
</div>
40+
41+
<AnimatePresence>
42+
{isLoading && (
43+
<motion.div
44+
animate={{ opacity: 1, transform: 'translateY(0px)' }}
45+
className="absolute inset-0 flex items-center justify-center"
46+
exit={{ opacity: 0, transform: 'translateY(20px)' }}
47+
initial={{ opacity: 0, transform: 'translateY(-20px)' }}
48+
transition={{ type: 'spring', duration: 0.4, bounce: 0 }}
49+
>
50+
<Loader small />
51+
</motion.div>
52+
)}
53+
</AnimatePresence>
54+
</div>
55+
</Comp>
56+
);
57+
}
Lines changed: 5 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import { cva, type VariantProps } from 'class-variance-authority';
2-
import { useTranslations } from 'next-intl';
3-
import { Slot } from 'radix-ui';
42
import * as React from 'react';
53

6-
import { cn } from '@/lib/utils';
7-
8-
import { Loader } from './loader';
4+
import { ClientButton } from './button-client';
95

106
const buttonVariants = cva(
11-
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
7+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer overflow-hidden",
128
{
139
variants: {
1410
variant: {
@@ -40,52 +36,17 @@ const buttonVariants = cva(
4036
},
4137
);
4238

43-
type ButtonProps = React.ComponentProps<'button'> &
39+
export type ButtonProps = React.ComponentProps<'button'> &
4440
VariantProps<typeof buttonVariants> & {
4541
asChild?: boolean;
4642
isLoading?: boolean;
47-
loadingText?: string;
4843
} & (
4944
| { 'aria-label': string; size: 'icon' }
5045
| { 'aria-label'?: string; size?: 'default' | 'lg' | 'sm' }
5146
);
5247

53-
function Button({
54-
className,
55-
variant,
56-
size,
57-
asChild = false,
58-
isLoading,
59-
loadingText,
60-
...props
61-
}: ButtonProps) {
62-
const t = useTranslations('core.global');
63-
const Comp = asChild ? Slot.Root : 'button';
64-
65-
if (isLoading) {
66-
const text = loadingText ?? t('loading');
67-
68-
return (
69-
<Comp
70-
className={cn(buttonVariants({ variant, size, className }))}
71-
{...props}
72-
aria-label={text}
73-
disabled
74-
type="button"
75-
>
76-
<Loader small />
77-
<span className="truncate">{size !== 'icon' && text}</span>
78-
</Comp>
79-
);
80-
}
81-
82-
return (
83-
<Comp
84-
className={cn(buttonVariants({ variant, size, className }))}
85-
data-slot="button"
86-
{...props}
87-
/>
88-
);
48+
function Button(props: ButtonProps) {
49+
return <ClientButton {...props} />;
8950
}
9051

9152
export { Button, buttonVariants };

packages/vitnode/src/components/ui/loader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const Loader = ({
1010
small?: boolean;
1111
}) => {
1212
if (small) {
13-
return <Loader2 className={cn('size-4 animate-spin', className)} />;
13+
return <Loader2 className={cn('size-5 animate-spin', className)} />;
1414
}
1515

1616
return (

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)