Stacked Input Form
A progressive form that stacks input fields vertically with smooth animations, allowing users to focus on one field at a time while maintaining context.
Install dependencies
npm i framer-motion
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 stacked-input-form.tsx in your components folder and paste this code
'use client';
import React, { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label';
import { MultiSelect } from '@/components/ui/multi-select';
import { AnimatePresence, motion, Transition } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ArrowRight, Check, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
const CompleteInput = ({
label,
type,
placeholder,
options,
hideLabel,
disabled,
expanded
}: {
label: string;
type: 'text' | 'select' | 'radio' | 'textarea' | 'multi-select' | 'slider';
placeholder?: string;
options?: { value: string; label: string }[];
hideLabel?: boolean;
disabled?: boolean;
expanded?: boolean;
}) => {
const id = `field-${label.toLowerCase().replace(/s+/g, '-')}`;
if (type === 'text') {
return (
<div className="flex w-[300px] flex-col gap-1">
<Label
htmlFor={id}
className={cn('text-sm font-medium', (!expanded || hideLabel) && 'opacity-0 select-none')}
>
{label}
</Label>
<div>
<Input
id={id}
placeholder={placeholder}
className="bg-black text-white border-black"
autoComplete="off"
disabled={disabled}
/>
</div>
</div>
);
} else if (type === 'select') {
return (
<div className="flex w-[300px] flex-col gap-1">
<Label
htmlFor={id}
className={cn('text-sm font-medium', (!expanded || hideLabel) && 'opacity-0 select-none')}
>
{label}
</Label>
<div>
<Select disabled={disabled}>
<SelectTrigger className="bg-black border-black text-white placeholder:text-white w-full">
<SelectValue placeholder={placeholder ?? 'Select…'} />
</SelectTrigger>
<SelectContent className="bg-black text-white border-none outline-none ring-0">
{options?.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="bg-black hover:!text-black focus:!bg-white/10 focus:!text-white border-none outline-none ring-0"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
} else if (type === 'radio') {
return (
<div className="flex w-[300px] flex-col gap-1">
<Label
htmlFor={id}
className={cn('text-sm font-medium', (!expanded || hideLabel) && 'opacity-0 select-none')}
>
{label}
</Label>
<div>
<RadioGroup
defaultValue={options?.[0]?.value}
className="grid grid-cols-2 gap-1 w-full bg-white h-9 border rounded-md px-2"
disabled={disabled}
>
{options?.map((opt) => {
const radioId = `${id}-${opt.value}`;
return (
<div key={opt.value} className="flex items-center gap-1">
<RadioGroupItem id={radioId} value={opt.value} />
<Label htmlFor={radioId} className="text-sm">
{opt.label}
</Label>
</div>
);
})}
</RadioGroup>
</div>
</div>
);
} else if (type === 'textarea') {
return (
<div className="flex w-[300px] flex-col gap-1">
<Label
htmlFor={id}
className={cn('text-sm font-medium', (!expanded || hideLabel) && 'opacity-0 select-none')}
>
{label}
</Label>
<div>
<Textarea
id={id}
placeholder={placeholder}
className="bg-black text-white border-black"
autoComplete="off"
disabled={disabled}
style={{
resize: 'none'
}}
/>
</div>
</div>
);
} else if (type === 'multi-select') {
return (
<div className="flex w-[300px] flex-col gap-1">
<Label
htmlFor={id}
className={cn('text-sm font-medium', (!expanded || hideLabel) && 'opacity-0 select-none')}
>
{label}
</Label>
<div>
<MultiSelect options={options ?? []} placeholder={placeholder ?? 'Select…'} />
</div>
</div>
);
} else if (type === 'slider') {
return (
<div className="flex w-[300px] flex-col gap-1">
<Label
htmlFor={id}
className={cn('text-sm font-medium', (!expanded || hideLabel) && 'opacity-0 select-none')}
>
{label}
</Label>
<div className="bg-white border rounded-md px-3 py-4">
<Slider min={0} max={5} defaultValue={[2]} disabled={disabled} />
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span>0</span>
<span>5</span>
</div>
</div>
</div>
);
}
return null;
};
export type Field = {
label: string;
type: 'text' | 'select' | 'radio' | 'textarea' | 'multi-select' | 'slider';
placeholder?: string;
options?: { value: string; label: string }[];
};
const StackedInputForm = ({ fields }: { fields: Field[] }) => {
const [currentFieldIndex, setCurrentFieldIndex] = useState(0);
const [showAll, setShowAll] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeTag = (document.activeElement?.tagName || '').toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea') return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
setCurrentFieldIndex((i) => Math.max(0, i - 1));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
setCurrentFieldIndex((i) => Math.min(fields.length - 1, i + 1));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const layoutTransition: Transition = showAll
? {
type: 'spring',
stiffness: 700,
damping: 50
}
: {
type: 'spring',
stiffness: 300,
damping: 30
};
return (
<motion.div layout className="flex flex-col">
<motion.div layout="position" transition={layoutTransition} className="z-10">
<div className={cn('flex items-center justify-between w-[300px]')}>
<Label className="text-left text-lg font-medium">Make your Pizza</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAll(!showAll)}
className="text-xs"
>
<AnimatePresence mode="wait">
{showAll ? (
<motion.p
key="collapse"
initial={{
opacity: 0
}}
animate={{
opacity: 1
}}
exit={{
opacity: 0
}}
transition={{
duration: 0.1
}}
className="flex items-center gap-1"
>
Collapse <ChevronsDownUp size={14} className="!h-3.5 !w-3.5" />
</motion.p>
) : (
<motion.p
key="expand"
initial={{
opacity: 0
}}
animate={{
opacity: 1
}}
exit={{
opacity: 0
}}
transition={{
duration: 0.1
}}
className="flex items-center gap-1"
>
Expand <ChevronsUpDown size={14} className="!h-3.5 !w-3.5" />
</motion.p>
)}
</AnimatePresence>
</Button>
</div>
</motion.div>
<motion.div
layout="position"
transition={layoutTransition}
className={cn(
'relative w-[300px] flex flex-col flex-nowrap items-start justify-start gap-2 mt-0 mb-2',
!showAll && 'min-h-[60.5px]'
)}
>
<AnimatePresence mode="popLayout">
{fields.map((field, index) => {
if (index > currentFieldIndex || (!showAll && currentFieldIndex - index >= 5))
return null;
return (
<motion.div
key={field.label + index}
style={{
zIndex: index + 1
}}
layout="position"
initial={{ opacity: 0, y: 6, scale: 1.05, rotateX: 10 }}
exit={{ opacity: 0, y: 6, scale: 1.05, rotateX: 10 }}
animate={{
y: !showAll ? -(currentFieldIndex - index) * 6 : 0,
rotateX: 0,
opacity: !showAll ? [1, 0.75, 0.5, 0.25, 0][currentFieldIndex - index] : 1,
scale: !showAll ? [1, 0.95, 0.9, 0.85, 0.8][currentFieldIndex - index] : 1
}}
transition={
showAll
? {
type: 'spring',
stiffness: 700,
damping: 50
}
: {
type: 'spring',
stiffness: 300,
damping: 30
}
}
className={cn(!showAll && 'absolute', 'shrink-0')}
>
<CompleteInput
label={field.label}
type={field.type}
placeholder={field.placeholder}
options={field.options}
hideLabel={showAll ? false : index !== currentFieldIndex}
expanded={showAll}
/>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
<motion.div
layout="position"
transition={layoutTransition}
className={cn(
'flex max-w-[300px] w-full items-center justify-between z-10',
showAll
? 'mt-0'
: ['textarea', 'slider'].includes(fields[currentFieldIndex].type)
? 'mt-6'
: 'mt-0'
)}
>
<AnimatePresence mode="wait">
{currentFieldIndex > 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key="back-button"
>
<Button
variant="ghost"
onClick={() => setCurrentFieldIndex((i) => Math.max(0, i - 1))}
disabled={currentFieldIndex === 0}
size="sm"
className="rounded-full active:scale-95 select-none"
>
<ArrowLeft size={14} className="!h-3.5 !w-3.5" />
</Button>
</motion.div>
) : (
<div></div>
)}
</AnimatePresence>
<Button
onClick={() => setCurrentFieldIndex((i) => Math.min(i + 1, fields.length - 1))}
disabled={currentFieldIndex === fields.length - 1}
size="sm"
className="rounded-full active:scale-95 select-none"
>
<AnimatePresence mode="wait">
{currentFieldIndex === fields.length - 1 ? (
<motion.p
key="next"
initial={{
opacity: 0
}}
animate={{
opacity: 1
}}
exit={{
opacity: 0
}}
transition={{
duration: 0.1
}}
className="flex items-center gap-1 text-xs"
>
Done <Check size={14} className="!h-3.5 !w-3.5" />
</motion.p>
) : (
<motion.p
key="next"
initial={{
opacity: 0
}}
animate={{
opacity: 1
}}
exit={{
opacity: 0
}}
transition={{
duration: 0.1
}}
className="flex items-center gap-1 text-xs"
>
Next <ArrowRight size={14} className="!h-3.5 !w-3.5" />
</motion.p>
)}
</AnimatePresence>
</Button>
</motion.div>
</motion.div>
);
};
export default StackedInputForm;
Usage
<StackedInputForm
fields={[
{
label: 'Size',
type: 'select',
placeholder: 'Select a size',
options: [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
{ value: 'xl', label: 'XL' }
]
},
{
label: 'Crust',
type: 'select',
placeholder: 'Select a crust',
options: [
{ value: 'thin', label: 'Thin' },
{ value: 'hand-tossed', label: 'Hand Tossed' },
{ value: 'deep-dish', label: 'Deep Dish' },
{ value: 'gluten-free', label: 'Gluten-Free' }
]
},
{
label: 'Sauce',
type: 'select',
placeholder: 'Select a sauce',
options: [
{ value: 'tomato', label: 'Tomato' },
{ value: 'bbq', label: 'BBQ' },
{ value: 'alfredo', label: 'Alfredo' },
{ value: 'pesto', label: 'Pesto' }
]
},
{
label: 'Cheese',
type: 'select',
placeholder: 'Select a cheese',
options: [
{ value: 'mozzarella', label: 'Mozzarella' },
{ value: 'cheddar', label: 'Cheddar' },
{ value: 'vegan', label: 'Vegan' },
{ value: 'no-cheese', label: 'No Cheese' }
]
},
{
label: 'Toppings',
type: 'multi-select',
placeholder: 'Select toppings',
options: [
{ value: 'pepperoni', label: 'Pepperoni' },
{ value: 'mushrooms', label: 'Mushrooms' },
{ value: 'onions', label: 'Onions' },
{ value: 'olives', label: 'Olives' },
{ value: 'bell-peppers', label: 'Bell Peppers' },
{ value: 'jalapenos', label: 'Jalapeños' },
{ value: 'pineapple', label: 'Pineapple' },
{ value: 'basil', label: 'Basil' }
]
},
{
label: 'Special Instructions',
type: 'textarea',
placeholder: 'Allergies, preferences, or delivery notes.'
},
{
label: 'Name',
type: 'text',
placeholder: 'John Doe'
},
{
label: 'Phone',
type: 'text',
placeholder: '123-456-7890'
},
{
label: 'Email',
type: 'text',
placeholder: 'john.doe@example.com'
},
{
label: 'Address',
type: 'text',
placeholder: '123 Main St, Anytown, USA'
}
]}
/>