Skip to content

Commit 8f9c837

Browse files
committed
feat(ui): GdgButton에 클릭 위치 기반 ripple 인터랙션 추가 및 커서/pressed 상태 개선
1 parent f2f4630 commit 8f9c837

1 file changed

Lines changed: 91 additions & 17 deletions

File tree

src/components/ui/design-system/GdgButton.tsx

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
'use client'
22

33
import Link from 'next/link'
4-
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
4+
import {
5+
type AnchorHTMLAttributes,
6+
type ButtonHTMLAttributes,
7+
type MouseEvent,
8+
type ReactNode,
9+
useRef,
10+
useState
11+
} from 'react'
512
import { cn } from '@/utils/cn'
613
import { getMobileWidthClass, getPcWidthClass, isWideOnlyWidth } from './controlMeta'
714
import type { Device, PcWidthVariant, WidthToken } from './controlMeta'
@@ -33,6 +40,14 @@ export type GdgButtonProps = {
3340
loading?: boolean
3441
} & Omit<ButtonLike, 'as'>
3542

43+
type Ripple = {
44+
id: number
45+
x: number
46+
y: number
47+
size: number
48+
active: boolean
49+
}
50+
3651
const SIZE_CLASS: Record<Device, Record<ButtonSize, string>> = {
3752
pc: {
3853
large: 'h-13 px-12 text-base leading-6',
@@ -71,6 +86,8 @@ export function GdgButton({
7186
}: GdgButtonProps) {
7287
const isDisabled = disabled || variant === 'disabled' || loading
7388
const effectiveVariant = isDisabled ? 'disabled' : variant
89+
const [ripples, setRipples] = useState<Ripple[]>([])
90+
const rippleIdRef = useRef(0)
7491

7592
const defaultPcVariant: PcWidthVariant =
7693
widthToken && isWideOnlyWidth(widthToken) ? 'wide' : 'narrow'
@@ -85,29 +102,74 @@ export function GdgButton({
85102
: SIZE_CLASS[device][size].split(' ').find((c) => c.startsWith('w-')) || ''
86103

87104
const classes = cn(
88-
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full border font-medium transition-all duration-200 ease-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/40 font-pretendard shrink-0',
105+
'relative isolate inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full border font-medium overflow-hidden transition-all duration-200 ease-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/40 font-pretendard shrink-0',
89106
SIZE_CLASS[device][size].replace(/w-\S+/g, ''),
90107
widthClass,
91108
VARIANT_CLASS[effectiveVariant],
92109
effectiveVariant === 'white' && (device === 'pc' ? 'typo-pc-s3' : 'typo-m-s2'),
110+
!isDisabled && 'cursor-pointer active:scale-[0.98] active:translate-y-px',
93111
isDisabled && 'pointer-events-none cursor-not-allowed opacity-70',
94112
className
95113
)
114+
const rippleColorClass = effectiveVariant === 'white' ? 'bg-black/20' : 'bg-white/35'
115+
116+
const triggerRipple = (event: MouseEvent<HTMLElement>) => {
117+
if (isDisabled) return
118+
119+
const element = event.currentTarget as HTMLElement
120+
const rect = element.getBoundingClientRect()
121+
const size = Math.max(rect.width, rect.height) * 2
122+
const isKeyboardClick = event.detail === 0
123+
const x = isKeyboardClick ? rect.width / 2 : event.clientX - rect.left
124+
const y = isKeyboardClick ? rect.height / 2 : event.clientY - rect.top
125+
const id = rippleIdRef.current++
126+
127+
setRipples((prev) => [...prev, { id, x, y, size, active: false }])
128+
129+
requestAnimationFrame(() => {
130+
setRipples((prev) =>
131+
prev.map((ripple) => (ripple.id === id ? { ...ripple, active: true } : ripple))
132+
)
133+
})
134+
135+
window.setTimeout(() => {
136+
setRipples((prev) => prev.filter((ripple) => ripple.id !== id))
137+
}, 550)
138+
}
96139

97140
const inner = (
98141
<>
99-
{loading && (
100-
<span
101-
className="size-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
102-
aria-hidden
103-
/>
104-
)}
105-
{icon && (
106-
<span className="shrink-0" aria-hidden>
107-
{icon}
108-
</span>
109-
)}
110-
{children}
142+
<span className="pointer-events-none absolute inset-0">
143+
{ripples.map((ripple) => (
144+
<span
145+
key={ripple.id}
146+
className={cn('absolute rounded-full', rippleColorClass)}
147+
style={{
148+
left: ripple.x,
149+
top: ripple.y,
150+
width: ripple.size,
151+
height: ripple.size,
152+
transform: `translate(-50%, -50%) scale(${ripple.active ? 1 : 0})`,
153+
opacity: ripple.active ? 0 : 0.35,
154+
transition: 'transform 550ms ease-out, opacity 550ms ease-out'
155+
}}
156+
/>
157+
))}
158+
</span>
159+
<span className="relative z-10 inline-flex items-center justify-center gap-2">
160+
{loading && (
161+
<span
162+
className="size-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
163+
aria-hidden
164+
/>
165+
)}
166+
{icon && (
167+
<span className="shrink-0" aria-hidden>
168+
{icon}
169+
</span>
170+
)}
171+
{children}
172+
</span>
111173
</>
112174
)
113175

@@ -118,22 +180,34 @@ export function GdgButton({
118180
}
119181

120182
if (as === 'a') {
183+
const anchorProps = rest as AnchorHTMLAttributes<HTMLAnchorElement>
184+
121185
return (
122186
<Link
123-
{...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}
187+
{...anchorProps}
124188
href={href ?? '#'}
189+
onClick={(event) => {
190+
triggerRipple(event)
191+
anchorProps.onClick?.(event)
192+
}}
125193
{...commonProps}
126194
>
127195
{inner}
128196
</Link>
129197
)
130198
}
131199

200+
const buttonProps = rest as ButtonHTMLAttributes<HTMLButtonElement>
201+
132202
return (
133203
<button
134-
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
135-
type={(rest as ButtonHTMLAttributes<HTMLButtonElement>).type ?? 'button'}
204+
{...buttonProps}
205+
type={buttonProps.type ?? 'button'}
136206
disabled={isDisabled}
207+
onClick={(event) => {
208+
triggerRipple(event)
209+
buttonProps.onClick?.(event)
210+
}}
137211
{...commonProps}
138212
>
139213
{inner}

0 commit comments

Comments
 (0)