Dialog Form
An interactive form component that transforms from a button into a modal dialog with smooth animations. Features form validation, loading states, and success feedback with beautiful visual effects.
Install dependencies
npm i framer-motion lucide-react usehooks-ts
Utility function
Create a file lib/utils.ts and paste this code
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Component
Create a file dialog-form.tsx in your components folder and paste this code
'use client';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { LoaderCircle, X } from 'lucide-react';
import React, { RefObject, useRef, useState } from 'react';
import { useOnClickOutside } from 'usehooks-ts';
interface SubmitDialogFormButtonProps {
onClick?: () =>
| Promise<{ success: boolean; message: string }>
| { success: boolean; message: string };
state?: 'idle' | 'loading' | 'success' | 'error';
}
export const SubmitDialogFormButton = ({
onClick,
state = 'idle'
}: SubmitDialogFormButtonProps) => {
const handleClick = async () => {
if (onClick) {
return await onClick();
}
};
const buttonVariants = {
idle: {
label: 'Submit',
icon: null,
animate: {
backgroundColor: '#3b82f6',
color: '#ffffff'
}
},
loading: {
label: null,
icon: <LoaderCircle className="animate-spin" size={20} />,
animate: {
backgroundColor: '#1e40af',
color: '#ffffff'
}
},
success: {
label: 'Submitted',
icon: null,
animate: {
backgroundColor: '#059669',
color: '#ffffff'
}
},
error: {
label: 'Try Again',
icon: null,
animate: {
x: [0, 2, -2, 0, -2, 2, 0, -2, 2, 0, -2, 2, 0],
backgroundColor: '#dc2626',
color: '#ffffff'
}
}
};
const entryExitAnimation = {
initial: {
opacity: 0,
y: '-100%'
},
animate: {
opacity: 1,
y: 0
},
exit: {
opacity: 0,
y: '100%'
},
transition: {
type: 'spring',
stiffness: 600,
damping: 30
}
};
return (
<button
type="button"
disabled={state === 'loading' || state === 'success'}
className="disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97] disabled:active:scale-100"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
animate={buttonVariants[state].animate}
transition={{
duration: 0.3,
ease: 'easeInOut'
}}
onClick={handleClick}
className="text-sm px-3 py-1.5 rounded-md flex items-center gap-1 min-w-[100px] justify-center overflow-hidden text-white bg-blue-600 hover:shadow-lg transition-shadow cursor-pointer"
>
{buttonVariants[state].label && (
<motion.span key={`label-${state}`} {...entryExitAnimation}>
{buttonVariants[state].label}
</motion.span>
)}
{buttonVariants[state].icon && (
<motion.span key={`icon-${state}`} {...entryExitAnimation}>
{buttonVariants[state].icon}
</motion.span>
)}
</motion.div>
</AnimatePresence>
</button>
);
};
interface DialogFormProps {
label: string;
successText: string;
successIcon: React.ReactNode;
icon?: React.ReactNode;
buttonClassName?: string;
containerClassName?: string;
childComponent?: React.ReactNode;
onSubmit?: () =>
| Promise<{ success: boolean; message: string }>
| { success: boolean; message: string };
onClose?: () => void;
}
const DialogForm = ({
label,
successText,
successIcon,
icon,
buttonClassName,
containerClassName,
childComponent = null,
onSubmit,
onClose
}: DialogFormProps) => {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const handleClose = () => {
if (formState === 'loading') return;
onClose?.();
setOpen(false);
setError(null);
setFormState('idle');
};
const dialogRef = useRef<HTMLDivElement>(null);
useOnClickOutside(dialogRef as RefObject<HTMLDivElement>, () => handleClose());
const handleSubmit = async () => {
let res = { success: false, message: 'Something went wrong.' };
setFormState('loading');
setError(null);
if (onSubmit) {
try {
res = await onSubmit();
if (res.success === true) {
setFormState('success');
setTimeout(() => {
handleClose();
}, 1500);
} else {
setFormState('error');
setError((res as { message: string }).message);
}
} catch (error) {
console.error(error);
setFormState('error');
setError('Something went wrong.');
}
} else {
setFormState('idle');
}
return res;
};
return (
<div>
<AnimatePresence mode="wait">
{!open ? (
<motion.button
layout
layoutId="form-container"
transition={{
type: 'spring',
duration: 0.3,
bounce: 0.05
}}
whileTap={{
scale: 0.97
}}
type="button"
onClick={() => setOpen(true)}
className={cn(
'border border-white/10 hover:border-white/20 bg-black px-4 py-2.5 rounded-xl',
buttonClassName
)}
>
<motion.p layout layoutId="form-label" className="flex items-center gap-2 text-sm">
{icon} {label}
</motion.p>
</motion.button>
) : (
<motion.div
ref={dialogRef}
layout
layoutId="form-container"
transition={{
layout: {
type: 'spring',
duration: 0.4,
bounce: 0.05
}
}}
key="form-container"
className={cn(
'shadow-md rounded-2xl flex flex-col min-w-[400px] bg-[#111111] overflow-hidden relative',
containerClassName
)}
>
<div className="p-4">
<div className="flex items-center justify-between">
<motion.p
layout
layoutId="form-label"
transition={{
layout: {
type: 'spring',
duration: 0.6,
bounce: 0.05
}
}}
className="flex items-center gap-2 text-sm"
>
{icon} {label}
</motion.p>
<button
type="button"
onClick={() => handleClose()}
className="p-1 hover:bg-white/5"
>
<X size={16} />
</button>
</div>
<AnimatePresence mode="popLayout">
{childComponent && (
<motion.div
key="child-component"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="mt-4"
>
{childComponent}
</motion.div>
)}
<motion.div
key="submit-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex justify-between mt-3"
>
<p className="text-sm text-red-500">{error}</p>
<SubmitDialogFormButton onClick={handleSubmit} state={formState} />
</motion.div>
</AnimatePresence>
{open && formState === 'success' && (
<>
<motion.div
key="shine-effect"
initial={{
x: '-100%'
}}
animate={{
x: '100%'
}}
transition={{
duration: 1.2,
ease: [0.4, 0, 0.2, 1],
delay: 0.1
}}
className="absolute inset-0 z-30 pointer-events-none"
>
<div className="w-full h-full bg-gradient-to-r from-transparent via-white/20 to-transparent -skew-x-12" />
</motion.div>
<motion.div
key="secondary-shine"
initial={{
x: '-100%'
}}
animate={{
x: '100%'
}}
transition={{
duration: 0.8,
ease: [0.4, 0, 0.2, 1],
delay: 0.3
}}
className="absolute inset-0 z-30 pointer-events-none"
>
<div className="w-full h-full bg-gradient-to-r from-transparent via-green-400/30 to-transparent -skew-x-12" />
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.1,
ease: 'easeOut'
}}
key="blur-effect"
className="backdrop-blur-sm absolute inset-0 z-10 rounded-3xl"
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
delay: 0.1
}}
key="success-content"
className="p-4 flex flex-col items-center justify-center absolute inset-0 z-20"
>
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{
duration: 0.5,
ease: [0.4, 0, 0.2, 1],
delay: 0.4
}}
>
{successIcon}
</motion.div>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
ease: 'easeOut',
delay: 0.6
}}
className="mt-2 font-medium"
>
{successText}
</motion.p>
</motion.div>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default DialogForm;
Usage
'use client';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import DialogForm from '../ui/dialog-form';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { BadgeCheck, Bug } from 'lucide-react';
const DialogFormDemo = () => {
const [shouldSucceed, setShouldSucceed] = useState(true);
const { register, watch, reset } = useForm({
resolver: yupResolver(
yup.object().shape({
name: yup.string().required('Name is required'),
email: yup.string().email('Invalid email').required('Email is required'),
details: yup.string().required('Details are required')
})
)
});
const ChildComponent = () => {
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<Input
placeholder="Name"
autoComplete="off"
className="border-white/10 hover:border-white/15 bg-black"
{...register('name')}
/>
<Input
placeholder="Email"
autoComplete="off"
className="border-white/10 hover:border-white/15 bg-black"
{...register('email')}
/>
</div>
<Textarea
placeholder="Provide details about the issue..."
autoComplete="off"
className="border-white/10 hover:border-white/15 bg-black"
rows={5}
style={{
resize: 'none'
}}
{...register('details')}
/>
</div>
);
};
const onCloseCb = () => {
reset();
};
const handleSubmit = async () => {
const formData = watch();
console.log(formData);
await new Promise((resolve) => setTimeout(resolve, 2000));
setShouldSucceed(!shouldSucceed);
const response = shouldSucceed
? { success: true, message: 'Form submitted successfully' }
: { success: false, message: 'Something went wrong' };
return response;
};
return (
<div>
<DialogForm
icon={<Bug size={16} />}
label="Report a Bug"
successIcon={<BadgeCheck size={40} />}
successText="Reported Successfully"
childComponent={<ChildComponent />}
onSubmit={handleSubmit}
onClose={onCloseCb}
/>
</div>
);
};
export default DialogFormDemo;