11"use client" ;
22
33import * as React from "react" ;
4+ import { motion , useReducedMotion } from "motion/react" ;
45import { Button } from "@/components/ui/button" ;
56import { Input } from "@/components/ui/input" ;
67import { Textarea } from "@/components/ui/textarea" ;
@@ -21,6 +22,29 @@ export function ContactForm() {
2122
2223 const isSubmitting = status === "submitting" ;
2324 const isDisabled = ! agree || isSubmitting ;
25+ const reduceMotion = useReducedMotion ( ) ;
26+ const isFormValid = React . useMemo (
27+ ( ) => ContactFormSchema . safeParse ( { email, message, agree, _hp : hp } ) . success ,
28+ [ email , message , agree , hp ] ,
29+ ) ;
30+ const shouldPulse = isFormValid && ! isSubmitting && ! reduceMotion ;
31+
32+ const fieldStagger = {
33+ hidden : { } ,
34+ show : reduceMotion
35+ ? { transition : { staggerChildren : 0 } }
36+ : { transition : { staggerChildren : 0.07 , delayChildren : 0.02 } } ,
37+ } ;
38+ const fieldItem = {
39+ hidden : reduceMotion ? { opacity : 0 } : { opacity : 0 , y : 12 } ,
40+ show : reduceMotion
41+ ? { opacity : 1 , transition : { duration : 0.3 } }
42+ : {
43+ opacity : 1 ,
44+ y : 0 ,
45+ transition : { duration : 0.45 , ease : [ 0.22 , 1 , 0.36 , 1 ] as const } ,
46+ } ,
47+ } ;
2448
2549 async function handleSubmit ( event : React . FormEvent < HTMLFormElement > ) {
2650 event . preventDefault ( ) ;
@@ -85,7 +109,15 @@ export function ContactForm() {
85109 }
86110
87111 return (
88- < form onSubmit = { handleSubmit } noValidate className = "flex flex-col gap-5" >
112+ < motion . form
113+ onSubmit = { handleSubmit }
114+ noValidate
115+ className = "flex flex-col gap-5"
116+ variants = { fieldStagger }
117+ initial = "hidden"
118+ whileInView = "show"
119+ viewport = { { once : true , amount : 0.2 } }
120+ >
89121 { serverError && (
90122 < div
91123 role = "alert"
@@ -95,7 +127,7 @@ export function ContactForm() {
95127 </ div >
96128 ) }
97129
98- < div className = "flex flex-col gap-2" >
130+ < motion . div variants = { fieldItem } className = "flex flex-col gap-2" >
99131 < label
100132 htmlFor = "contact-email"
101133 className = "text-sm font-medium text-[var(--color-fg)]"
@@ -123,9 +155,9 @@ export function ContactForm() {
123155 { errors . email }
124156 </ p >
125157 ) }
126- </ div >
158+ </ motion . div >
127159
128- < div className = "flex flex-col gap-2" >
160+ < motion . div variants = { fieldItem } className = "flex flex-col gap-2" >
129161 < label
130162 htmlFor = "contact-message"
131163 className = "text-sm font-medium text-[var(--color-fg)]"
@@ -134,8 +166,8 @@ export function ContactForm() {
134166 </ label >
135167 < Textarea
136168 id = "contact-message"
137- placeholder = "필요한 일을 알려주세요."
138- rows = { 6 }
169+ placeholder = "문의 내용을 알려주세요."
170+ rows = { 4 }
139171 value = { message }
140172 onChange = { ( e ) => setMessage ( e . target . value ) }
141173 aria-invalid = { Boolean ( errors . message ) }
@@ -153,7 +185,7 @@ export function ContactForm() {
153185 { errors . message }
154186 </ p >
155187 ) }
156- </ div >
188+ </ motion . div >
157189
158190 { /* Honeypot — hidden from users, bots fill it */ }
159191 < input
@@ -167,7 +199,7 @@ export function ContactForm() {
167199 className = "absolute left-[-9999px] h-0 w-0 opacity-0"
168200 />
169201
170- < div className = "flex flex-col gap-2" >
202+ < motion . div variants = { fieldItem } className = "flex flex-col gap-2" >
171203 < Checkbox
172204 checked = { agree }
173205 onChange = { ( e ) => setAgree ( e . target . checked ) }
@@ -191,17 +223,38 @@ export function ContactForm() {
191223 { errors . agree }
192224 </ p >
193225 ) }
194- </ div >
226+ </ motion . div >
195227
196- < Button
197- type = "submit"
198- size = "lg"
199- disabled = { isDisabled }
200- aria-disabled = { isDisabled }
201- className = "self-start"
202- >
203- { isSubmitting ? "보내는 중…" : "보내기" }
204- </ Button >
205- </ form >
228+ < motion . div variants = { fieldItem } className = "self-start" >
229+ < motion . div
230+ animate = {
231+ shouldPulse
232+ ? {
233+ boxShadow : [
234+ "0 0 0 0 rgba(15,84,64,0.0)" ,
235+ "0 0 0 10px rgba(15,84,64,0.18)" ,
236+ "0 0 0 0 rgba(15,84,64,0.0)" ,
237+ ] ,
238+ }
239+ : { boxShadow : "0 0 0 0 rgba(15,84,64,0.0)" }
240+ }
241+ transition = {
242+ shouldPulse
243+ ? { duration : 1.6 , repeat : Infinity , ease : "easeOut" }
244+ : { duration : 0.2 }
245+ }
246+ style = { { borderRadius : 9999 } }
247+ >
248+ < Button
249+ type = "submit"
250+ size = "lg"
251+ disabled = { isDisabled }
252+ aria-disabled = { isDisabled }
253+ >
254+ { isSubmitting ? "보내는 중…" : "보내기" }
255+ </ Button >
256+ </ motion . div >
257+ </ motion . div >
258+ </ motion . form >
206259 ) ;
207260}
0 commit comments