See the live version of MultiForm
A reusable, multi-step form library built around a compound-component API and styled entirely in the neumorphism trend. Instead of hardcoding a single form, the project exposes a small set of composable building blocks (MultiForm.Section, MultiForm.Input, MultiForm.Select, MultiForm.Group) that you wire together declaratively, while validation, step navigation and shared state are handled behind the scenes by a dedicated context.
Main features:
- Three-step form with smooth back-and-forth navigation between steps
- Compound component API — compose the form declaratively from small pieces
- Per-step validation powered by React Hook Form + Zod
- Custom dropdown built without the native
<select>element - Animated radio and checkbox controls drawn entirely with CSS pseudo-elements
- Fully token-driven neumorphic design system in a single global stylesheet
The project uses node and npm (bootstrapped with Create React App). Having them installed, type into the terminal:
npm i
npm startThe app will be available at http://localhost:3000.
- Compound component pattern — the public API is assembled by attaching the sub-components onto the main one, so consumers compose the form with intuitive dot-notation (
MultiForm.Section,MultiForm.Input, etc.):
export default Object.assign(MultiForm, {
Group: MultiFormGroup,
Input: MultiFormInput,
Section: MultiFormSection,
Select: MultiFormSelect,
});Sections don't need to know which step they are on or which handler to call — the parent clones each child and injects the right props at render time:
{
React.Children.map(children, (child, index) => {
const step = child.props.$nr ?? index + 1;
const isActive = step === currentStep;
return React.cloneElement(child, {
$onSubmit: isLastStep ? handleFinalSubmit : handleNext,
$prevStep: !isFirstStep ? prevStep : null,
$display: isActive,
});
});
}
- Per-step validation — each section collects the field names of its own inputs and validates only those before advancing, so a user is never blocked by errors on a step they haven't reached yet:
const fields = [
...new Set(
React.Children.map(children, (child) => {
if (child.props.$type === 'submit') {
return null;
}
return child.props.$name;
}).filter(Boolean)
),
];const handleNext = useCallback(
async (fields) => {
const isValid = await trigger(fields);
if (isValid) {
nextStep();
}
},
[trigger, nextStep]
);
- Zod + React Hook Form — the whole form is driven by a single Zod schema plugged into React Hook Form through the resolver. Validation runs on touch and re-validates on change, which is what powers the immediate inline feedback:
const { register, handleSubmit, trigger, setValue, reset, formState } = useForm({
resolver: zodResolver($schema),
mode: 'onTouched',
reValidateMode: 'onChange',
});const schema = z.object({
firstName: z.string().min(1, 'Imię jest wymagane'),
email: z.string().email('Nieprawidłowy email'),
password: z.string().min(8, 'Hasło musi mieć minimum 8 znaków'),
skills: z
.array(z.enum(['React', 'TypeScript', 'Node.js', 'GraphQL']))
.min(1, 'Wybierz przynajmniej jedną umiejętność'),
});
- Custom select — instead of the native
<select>, the dropdown is a styled button list. A hidden input stays registered with React Hook Form, so the field validates like any other while the visible value is kept in a separate display state:
<input type="hidden" {...register($name)} />const handleValueChange = (option) => {
setValue($name, String(option));
setDisplayValue($name, String(option));
setIsOpen(false);
};
- Pseudo-element animations for radio & checkbox — the real input is visually hidden, and the entire control (track, dot/tick, checked state) is drawn on the label's
::beforeand::after. The:has()selector reacts to the input's:checkedstate with no JS:
const StyledMultiFormInputCheck = styled(StyledMultiFormInput)`
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
`;label::after {
content: '';
position: absolute;
left: 6px;
width: 10px;
height: 10px;
background: var(--neu-accent);
border-radius: $ {
$type==='radio'? '50%' : '2px';
}
transform: scale(0);
transition: transform 0.2s ease-in-out;
}
&:has(input:checked) label::after {
transform: scale(1);
}
- Unified semantic neumorphic style — the whole look lives in one global stylesheet as CSS custom properties. Shadow tokens are computed from a single
--neu-distance/--neu-blurpair, so the neumorphic depth can be re-tuned in one place, and components reference shadows by intent (--neu-shadow-out,--neu-shadow-in,--neu-shadow-flat) rather than raw values:
--neu-shadow-out:
var(--neu-distance) var(--neu-distance) var(--neu-blur) var(--neu-shadow-dark),
calc(var(--neu-distance) * -1) calc(var(--neu-distance) * -1) var(--neu-blur) var(--neu-shadow-light);
There are no runtime prop checks at the moment. Adding PropTypes would be a quick win, but the cleaner long-term move is rewriting the library to TypeScript — the prop contracts ($type, $name, $options) and the inferred Zod schema would pair very well with static types.
Right now the light/dark shadow colors are declared by hand in :root. A nicer system would derive the full neumorphic palette from a single base color, automatically calculating the lighter and darker shadow tones — changing one variable would re-theme everything.
The post-submit notification text is currently hardcoded. Exposing it as a prop (per form instance) would make the component reusable across different contexts without editing the source.
Single-step usage works mostly by not rendering the Prev button, which is more of a side effect than an explicit case. A dedicated branch for one-step forms would make the navigation logic clearer and less implicit.
Write sth nice ;) Find me on LinkedIn
Thanks to my Mentor Mateusz Bogolubow devmentor.pl — for providing me with this task and for the code review.


