Custom Components
Override default question renderers with custom React components for a unique look, feel, or behaviour.
Custom Text Input
A styled text input with floating label and error state:
FloatingInput.tsxtsx
import type { QuestionComponentProps } from 'react-minimal-survey-builder';
function FloatingInput({ question, value, onChange, error, disabled }: QuestionComponentProps) {
const val = (value as string) ?? '';
return (
<div className="relative mb-6">
<input
type={question.type === 'email' ? 'email' : 'text'}
value={val}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder=" "
className={`peer w-full px-4 pt-5 pb-2 rounded-xl border-2 transition-colors
bg-white dark:bg-gray-900 text-gray-900 dark:text-white
${error
? 'border-red-400 focus:border-red-500'
: 'border-gray-200 focus:border-blue-500'}
focus:outline-none disabled:opacity-50`}
/>
<label
className={`absolute left-4 top-2 text-xs transition-all
peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-base
peer-focus:top-2 peer-focus:text-xs
${error ? 'text-red-400' : 'text-gray-400 peer-focus:text-blue-500'}`}
>
{question.label}
{question.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{question.description && (
<p className="text-xs text-gray-400 mt-1 ml-1">{question.description}</p>
)}
{error && <p className="text-red-500 text-xs mt-1 ml-1">{error}</p>}
</div>
);
}Custom Radio Group
CardRadio.tsxtsx
function CardRadio({ question, value, onChange, error, disabled }: QuestionComponentProps) {
return (
<div className="mb-6">
<p className="font-medium mb-2">
{question.label}
{question.required && <span className="text-red-500 ml-0.5">*</span>}
</p>
<div className="grid grid-cols-2 gap-2">
{question.options?.map((opt) => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => onChange(opt.value)}
className={`p-3 rounded-xl border-2 text-sm font-medium transition-all
${value === opt.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300 text-gray-600'}
disabled:opacity-50 disabled:cursor-not-allowed`}
>
{opt.label}
</button>
))}
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
}Custom Textarea
StyledTextarea.tsxtsx
function StyledTextarea({ question, value, onChange, error, disabled }: QuestionComponentProps) {
const val = (value as string) ?? '';
const maxLength = question.validation?.find((r) => r.type === 'maxLength')?.value as number | undefined;
return (
<div className="mb-6">
<label className="block font-medium mb-1.5">
{question.label}
{question.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<textarea
value={val}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={question.placeholder}
rows={4}
className={`w-full px-4 py-3 rounded-xl border-2 transition-colors
bg-white dark:bg-gray-900 resize-none
${error ? 'border-red-400' : 'border-gray-200 focus:border-blue-500'}
focus:outline-none disabled:opacity-50`}
/>
<div className="flex justify-between mt-1">
{error ? (
<p className="text-red-500 text-xs">{error}</p>
) : <span />}
{maxLength && (
<span className="text-xs text-gray-400">{val.length}/{maxLength}</span>
)}
</div>
</div>
);
}Custom Rating (Emoji)
EmojiRating.tsxtsx
function EmojiRating({ question, value, onChange, error, disabled }: QuestionComponentProps) {
const emojis = [
{ emoji: '😡', label: 'Terrible', value: 1 },
{ emoji: '😕', label: 'Bad', value: 2 },
{ emoji: '😐', label: 'Okay', value: 3 },
{ emoji: '😊', label: 'Good', value: 4 },
{ emoji: '🤩', label: 'Amazing', value: 5 },
];
return (
<div className="mb-6">
<p className="font-medium mb-2">
{question.label}
{question.required && <span className="text-red-500 ml-0.5">*</span>}
</p>
<div className="flex gap-3 justify-center">
{emojis.map((e) => (
<button
key={e.value}
type="button"
disabled={disabled}
onClick={() => onChange(e.value)}
className={`flex flex-col items-center p-2 rounded-xl transition-all
${value === e.value ? 'scale-125 bg-blue-50' : 'opacity-60 hover:opacity-100'}
disabled:cursor-not-allowed`}
>
<span className="text-3xl">{e.emoji}</span>
<span className="text-xs text-gray-500 mt-1">{e.label}</span>
</button>
))}
</div>
{error && <p className="text-red-500 text-xs mt-2 text-center">{error}</p>}
</div>
);
}Putting It All Together
Complete Exampletsx
import { SurveyRenderer } from 'react-minimal-survey-builder';
import type { SurveySchema } from 'react-minimal-survey-builder';
const schema: SurveySchema = {
id: 'custom-ui-demo',
title: 'Feedback',
pages: [
{
id: 'page1',
questions: [
{ id: 'name', type: 'text', label: 'Name', required: true },
{ id: 'email', type: 'email', label: 'Email', required: true },
{
id: 'experience',
type: 'radio',
label: 'How was your experience?',
required: true,
options: [
{ label: 'Excellent', value: 'excellent' },
{ label: 'Good', value: 'good' },
{ label: 'Fair', value: 'fair' },
{ label: 'Poor', value: 'poor' },
],
},
{ id: 'rating', type: 'rating', label: 'Overall rating', required: true },
{
id: 'comments',
type: 'textarea',
label: 'Comments',
placeholder: 'Share your thoughts...',
validation: [{ type: 'maxLength', value: 500 }],
},
],
},
],
};
function App() {
return (
<SurveyRenderer
schema={schema}
className="max-w-lg mx-auto p-8 bg-white rounded-2xl shadow-xl"
components={{
text: FloatingInput,
email: FloatingInput,
radio: CardRadio,
textarea: StyledTextarea,
rating: EmojiRating,
}}
options={{
onSubmit: (answers) => console.log('Submitted:', answers),
}}
renderComplete={() => (
<div className="text-center py-8">
<p className="text-4xl mb-3">🎉</p>
<h2 className="text-xl font-bold">Thanks for your feedback!</h2>
</div>
)}
/>
);
}Tips
- - Custom components receive the same
QuestionComponentPropsinterface. - - Always handle
disabledanderrorprops for consistency. - - You can mix custom and default components — only override what you need.
- - See Custom Question Types for creating entirely new types.