인터랙션 설계
상태 피드백, 마이크로인터랙션, duration·easing·애니메이션 프리셋의 토큰화와 prefers-reduced-motion 지원으로 AI가 일관된 인터랙션을 구현하게 하는 설계.
핵심 요약
- 버튼·입력의 모든 상태(default·hover·focus·active·disabled·loading·error)를 스타일 토큰으로 정의하고, 상태 전이 다이어그램으로 흐름을 명시합니다.
- 마이크로인터랙션(button press, hover lift, focus ring, ripple 등)을 scale·duration·easing 값으로 토큰화하고 Tailwind 인터랙션 클래스로 제공합니다.
- Duration은 instant(50ms)~slowest(500ms) 스케일로, easing은 easeOut·easeIn·easeInOut·spring·bounce 함수로 표준화합니다.
- fadeIn·slideUp·scaleIn 같은 애니메이션 프리셋을 정의해 CSS keyframe으로 출력합니다.
- prefers-reduced-motion은 useReducedMotion 훅과 CSS 미디어 쿼리로 받아 접근성을 챙깁니다.
사용자 경험의 품질은 인터랙션의 디테일에서 갈립니다. AI가 일관된 인터랙션을 구현하려면 상태 피드백과 애니메이션을 먼저 토큰으로 정리해 둬야 합니다.
인터랙션 계층
상태 피드백 패턴
상태 전이 다이어그램
상태별 스타일 토큰
// tokens/states.ts
export const interactiveStates = {
button: {
default: {
background: '{colors.primary}',
foreground: '{colors.primary-foreground}',
border: 'transparent',
shadow: 'none',
},
hover: {
background: '{colors.primary-hover}',
transform: 'translateY(-1px)',
shadow: '{shadows.sm}',
},
focus: {
outline: '2px solid {colors.ring}',
outlineOffset: '2px',
},
active: {
background: '{colors.primary-active}',
transform: 'translateY(0)',
shadow: 'none',
},
disabled: {
background: '{colors.muted}',
foreground: '{colors.muted-foreground}',
cursor: 'not-allowed',
opacity: 0.5,
},
loading: {
cursor: 'wait',
pointerEvents: 'none',
},
},
input: {
default: {
border: '{colors.border}',
background: '{colors.background}',
},
hover: {
border: '{colors.border-hover}',
},
focus: {
border: '{colors.ring}',
ring: '1px solid {colors.ring}',
},
error: {
border: '{colors.destructive}',
ring: '1px solid {colors.destructive}',
},
disabled: {
background: '{colors.muted}',
cursor: 'not-allowed',
},
},
} as const피드백 컴포넌트
// components/feedback/loading-button.tsx
interface LoadingButtonProps extends ButtonProps {
loading?: boolean
loadingText?: string
}
/**
* 로딩 상태가 있는 버튼
* @ai-hint 비동기 액션(폼 제출, API 호출) 시 사용
*/
export function LoadingButton({
children,
loading,
loadingText,
disabled,
...props
}: LoadingButtonProps) {
return (
<Button
disabled={disabled || loading}
aria-busy={loading}
{...props}
>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
{loading ? (loadingText ?? children) : children}
</Button>
)
}마이크로인터랙션
인터랙션 유형
마이크로인터랙션 토큰
// tokens/interactions.ts
export const interactions = {
// 버튼 누름 효과
buttonPress: {
scale: 0.98,
duration: '100ms',
easing: 'ease-out',
},
// 호버 리프트 효과
hoverLift: {
translateY: '-2px',
shadow: '{shadows.md}',
duration: '200ms',
easing: 'ease-out',
},
// 포커스 링
focusRing: {
ringWidth: '2px',
ringColor: '{colors.ring}',
ringOffset: '2px',
transition: '150ms ease-out',
},
// 토글 전환
toggle: {
duration: '200ms',
easing: 'spring(1, 100, 10, 0)',
},
// 체크박스 체크
checkbox: {
scale: [1, 1.1, 1],
duration: '200ms',
},
// 스위치 슬라이드
switch: {
translateX: '100%',
duration: '200ms',
easing: 'ease-out',
},
// 리플 효과
ripple: {
scale: [0, 2],
opacity: [0.3, 0],
duration: '400ms',
},
} as constTailwind 인터랙션 클래스
// tailwind.config.ts
export const interactionClasses = {
// 기본 인터랙션
'interactive-base': 'transition-colors duration-200 ease-out',
// 버튼 인터랙션
'interactive-button': [
'transition-all duration-200 ease-out',
'hover:shadow-sm hover:-translate-y-0.5',
'active:translate-y-0 active:shadow-none',
'focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2',
].join(' '),
// 카드 인터랙션 (클릭 가능)
'interactive-card': [
'transition-all duration-200 ease-out',
'hover:shadow-md hover:-translate-y-1',
'active:shadow-sm active:translate-y-0',
].join(' '),
// 링크 인터랙션
'interactive-link': [
'transition-colors duration-150',
'hover:text-primary',
'focus-visible:outline-2 focus-visible:outline-ring',
].join(' '),
// 입력 인터랙션
'interactive-input': [
'transition-colors duration-150',
'hover:border-border-hover',
'focus:border-ring focus:ring-1 focus:ring-ring',
].join(' '),
}애니메이션 토큰화
Duration 스케일
// tokens/motion.ts
export const duration = {
/** 즉각 피드백 (hover, focus) */
instant: '50ms',
/** 빠른 전환 (색상 변경) */
fast: '100ms',
/** 일반 전환 (대부분의 UI) */
normal: '200ms',
/** 느린 전환 (모달 열기) */
slow: '300ms',
/** 복잡한 애니메이션 */
slower: '400ms',
/** 페이지 전환 */
slowest: '500ms',
} as constEasing 함수
// tokens/motion.ts
export const easing = {
/** 표준 감속 - 요소 진입 */
easeOut: 'cubic-bezier(0.0, 0.0, 0.2, 1)',
/** 표준 가속 - 요소 퇴장 */
easeIn: 'cubic-bezier(0.4, 0.0, 1, 1)',
/** 표준 가감속 - 상태 전환 */
easeInOut: 'cubic-bezier(0.4, 0.0, 0.2, 1)',
/** 강조 감속 - 주목 필요 */
emphasizedDecelerate: 'cubic-bezier(0.05, 0.7, 0.1, 1.0)',
/** 스프링 - 자연스러운 동작 */
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
/** 바운스 */
bounce: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
} as const애니메이션 프리셋
// tokens/animations.ts
export const animations = {
// 페이드 인
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 },
duration: '{duration.normal}',
easing: '{easing.easeOut}',
},
// 슬라이드 업
slideUp: {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'translateY(0)' },
duration: '{duration.normal}',
easing: '{easing.easeOut}',
},
// 스케일 인
scaleIn: {
from: { opacity: 0, transform: 'scale(0.95)' },
to: { opacity: 1, transform: 'scale(1)' },
duration: '{duration.fast}',
easing: '{easing.spring}',
},
// 스핀
spin: {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
duration: '1s',
easing: 'linear',
iterationCount: 'infinite',
},
// 펄스
pulse: {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.5 },
duration: '2s',
easing: '{easing.easeInOut}',
iterationCount: 'infinite',
},
// 바운스
bounce: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
duration: '1s',
easing: '{easing.bounce}',
iterationCount: 'infinite',
},
} as constCSS 애니메이션 출력
/* dist/animations.css */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fadeIn 200ms var(--ease-out) forwards;
}
.animate-slide-up {
animation: slideUp 200ms var(--ease-out) forwards;
}
.animate-scale-in {
animation: scaleIn 100ms var(--spring) forwards;
}Reduced Motion 지원
// hooks/use-reduced-motion.ts
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setReducedMotion(mediaQuery.matches)
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}, [])
return reducedMotion
}
// 사용 예시
function AnimatedComponent() {
const reducedMotion = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reducedMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.2,
}}
>
Content
</motion.div>
)
}/* CSS 레벨 지원 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}