인터랙션 설계
상태 피드백, 마이크로인터랙션, 애니메이션의 체계적 토큰화
사용자 경험의 품질은 인터랙션의 디테일에서 결정됩니다. 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;
}
}