dynamic-modal
v1.1.26
Published
The dynamic-modal is a solution of creation different modals into project using a json configuration file
Downloads
187
Maintainers
Readme
dynamic-modal
dynamic-modal is a React library for building configurable modals from JSON.
Instead of hand-writing a modal UI every time, you describe fields, actions, and
conditional behavior in a config object and open it through a hook.
It is designed for projects that want:
- reusable modal definitions
- dynamic forms inside modals
- conditional rendering with
renderIf - conditional enabling with
enableIf - dependent remote options with
liveData - full UI customization through your own design system components
Compatibility
According to package.json, this library is compatible with:
react:^18.0.0 || ^19.0.0react-dom:^18.0.0 || ^19.0.0react-hook-form:^7.54.2
The library itself is currently built with:
react:^19.0.0react-dom:^19.0.0react-hook-form:^7.54.2
It works well in React apps and in Next.js projects that support client components.
Installation
npm install dynamic-modalIf your project does not already include the required peers, install them too:
npm install react react-dom react-hook-formExports
The package exposes:
DynamicModaluseModalHandlerComponentStateComponentStateContextIComponentStateIModalConfigLoaderIModalConfigPropsIModalRenderConditionIModalFieldIModalLiveDataConditionIOption
Mental model
You use the library in 4 steps:
- Define the UI components the modal should use in your app.
- Wrap your app with
ComponentState. - Render
DynamicModaland control it withuseModalHandler. - Build modal configs as plain objects and open them when needed.
1. Provide your own components
dynamic-modal does not force a UI kit on you.
You provide your own inputs, selects, buttons, toggles, and textarea components
through ComponentState, so the modal matches your app visually.
Example:
'use client';
import { ReactNode } from 'react';
import {
Autocomplete,
AutocompleteItem,
Button,
Input,
Select,
SelectItem,
Switch,
Textarea,
} from '@heroui/react';
import type { IComponentState } from 'dynamic-modal';
export const modalComponents: IComponentState = {
ModalButtonCancel: ({ text, color, ...props }) => (
<Button {...props} color={color as any} variant="bordered">
{text}
</Button>
),
ModalButtonAction: ({ text, color, ...props }) => (
<Button {...props} color={color as any} variant="solid">
{text}
</Button>
),
Button: ({ text, color, variant, ...props }) => (
<Button {...props} color={color as any} variant={variant as any}>
{text}
</Button>
),
Input: ({ invalid, error, disabled, onChange, value, ...props }) => (
<Input
{...props}
value={value ?? ''}
onValueChange={onChange}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
/>
),
Select: ({
options,
invalid,
error,
isMulti,
isSearch,
disabled,
onChange,
value,
...props
}) =>
isSearch ? (
<Autocomplete
{...props}
selectedKey={value}
onSelectionChange={onChange as any}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
>
{options.map((item) => (
<AutocompleteItem key={item.id}>{item.name}</AutocompleteItem>
))}
</Autocomplete>
) : (
<Select
{...props}
selectedKeys={isMulti ? (value ?? []) : value ? [value] : []}
onSelectionChange={onChange as any}
selectionMode={isMulti ? 'multiple' : 'single'}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
>
{options.map((option) => (
<SelectItem key={option.id}>{option.name}</SelectItem>
))}
</Select>
),
Textarea: ({ invalid, error, disabled, value, onChange, ...props }) => (
<Textarea
{...props}
value={value ?? ''}
onValueChange={onChange}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
/>
),
Toggle: ({ value, onChange, label, ...props }) => (
<Switch {...props} isSelected={!!value} onValueChange={onChange}>
{label}
</Switch>
),
};
export function ModalProvider({ children }: { children: ReactNode }) {
return <ComponentState components={modalComponents}>{children}</ComponentState>;
}2. Add the provider and portal
Wrap your app with ComponentState and add a portal target with the id
modal-portal.
Next.js App Router
import type { ReactNode } from 'react';
export default function RootLayout({
children,
}: Readonly<{ children: ReactNode }>) {
return (
<html lang="en">
<body>
<ModalProvider>{children}</ModalProvider>
<div id="modal-portal" />
</body>
</html>
);
}Next.js Pages Router
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<div id="modal-portal" />
<NextScript />
</body>
</Html>
);
}3. Render and control the modal
Use useModalHandler to open the modal and render DynamicModal once in your
page or component tree.
'use client';
import { DynamicModal, useModalHandler } from 'dynamic-modal';
import { Button } from '@heroui/react';
import simpleModal from './modal-config/simple-modal';
export default function ExamplePage() {
const { openModal, modalProps } = useModalHandler();
return (
<>
<Button
onClick={() => {
openModal(
simpleModal.default(
{ reserved: 'abc', input1: 'Initial value', store: false },
(data) => {
console.log('modal result', data);
},
),
);
}}
>
Open modal
</Button>
<DynamicModal {...modalProps} />
</>
);
}4. Create modal configs
The recommended pattern is to define modal configs with IModalConfigLoader.
This lets you:
- receive input props
- return a typed modal config
- receive typed modal output in the
actioncallback
Basic example:
import type { IModalConfigLoader } from 'dynamic-modal';
type IncomingProps = {
reserved: string;
input1: string;
store?: boolean;
clear?: boolean;
};
type ResultProps = IncomingProps;
const simpleModal: {
default: IModalConfigLoader<IncomingProps, ResultProps>;
} = {
default: (props, action) => ({
reservedData: {
reserved: props.reserved,
},
title: 'Basic modal',
style: {
width: '500px',
},
fields: [
{
elementType: 'input',
label: 'Input 1',
name: 'input1',
defaultValue: props.input1,
validation: {
required: true,
message: 'This field is required',
},
},
{
elementType: 'group',
groups: [
{
elementType: 'toggle',
label: 'Store',
name: 'store',
defaultValue: `${props.store ?? false}`,
style: { width: '50%' },
validation: {
required: false,
},
},
{
elementType: 'toggle',
label: 'Clear',
name: 'clear',
defaultValue: `${props.clear ?? false}`,
style: { width: '50%' },
validation: {
required: false,
},
},
],
},
],
out: action,
actions: {
action: { text: 'Save', color: 'primary' },
cancel: { text: 'Cancel', color: 'danger' },
},
}),
};
export default simpleModal;Supported field types
You can build modal UIs with these field types:
inputselecttextareatoggletextuploadcustom-uploadwatcherbuttontablegroup
group lets you place multiple fields in the same row.
Conditional behavior
One of the main strengths of the library is dynamic behavior based on form state.
renderIf
Use renderIf when a field should appear only if another field matches one or
more values.
{
elementType: 'input',
label: 'Company name',
name: 'companyName',
validation: {
required: true,
message: 'Write a company name',
},
renderIf: {
personType: ['company'],
},
}You can also use '*' as a wildcard:
renderIf: {
personType: ['*'],
}enableIf
Use enableIf when a field should stay visible but only become editable if a
condition is met.
{
elementType: 'input',
label: 'Discount code',
name: 'discountCode',
validation: {
required: false,
},
enableIf: {
hasDiscount: ['true'],
},
}liveData
Use liveData when one field depends on another and must fetch options
dynamically.
{
elementType: 'select',
label: 'City',
name: 'cityId',
options: [],
validation: {
required: true,
message: 'Select a city',
},
liveData: {
condition: ['countryId'],
action: async (countryId, formData) => {
const response = await fetch(`/api/cities?countryId=${countryId}`);
const data = await response.json();
return data.map((city: { id: string; name: string }) => ({
id: city.id,
name: city.name,
}));
},
},
}watcher
Use watcher when you want to display a derived read-only value built from
other fields in the same modal.
watcher listens to the fields listed in watchList, joins their current
values, and renders the result using your custom Input component in disabled
mode.
Example:
{
elementType: 'watcher',
label: 'Full name preview',
watchList: ['firstName', 'middleName', 'lastName'],
style: {
width: '100%',
},
}Typical use cases:
- preview a full name from multiple inputs
- build a quick summary field for the user
- show a composed display value without storing it as a real form field
Advanced conditions with async actions
renderIf and enableIf can also use async logic instead of static value maps.
This is useful if the decision depends on the backend or on custom business
rules.
Example:
renderIf: {
condition: ['customerId'],
action: async (customerId, formData) => {
const response = await fetch(`/api/customers/${customerId}/can-edit`);
const data = await response.json();
return data.allowed;
},
}The same shape works for enableIf.
Variants and combinations (renderIf, enableIf, liveData)
The library supports these variants:
| Feature | Variant | Shape |
| --- | --- | --- |
| renderIf | static criteria | renderIf: { fieldName: ['value1', 'value2'] } |
| renderIf | wildcard | renderIf: { fieldName: ['*'] } |
| renderIf | async action | renderIf: { condition: ['fieldName'], action: async (...) => boolean } |
| enableIf | static criteria | enableIf: { fieldName: ['value1', 'value2'] } |
| enableIf | wildcard | enableIf: { fieldName: ['*'] } |
| enableIf | async action | enableIf: { condition: ['fieldName'], action: async (...) => boolean } |
| liveData | single trigger field | liveData: { condition: ['fieldName'], action: async (...) => IOption[] } |
| liveData | multiple trigger fields | liveData: { condition: ['fieldA', 'fieldB'], action: async (...) => IOption[] } |
Supported combinations by field type:
input,textarea,toggle,upload,custom-upload:renderIf+enableIfselect:renderIf+enableIf+liveDatatable:renderIf+liveDatawatcher: norenderIf/enableIf/liveDatacontract in its interface
Behavior note about multiple observed fields:
- In static mode (
Record<field, values>), conditions are evaluated per field-change event. - In async mode (
condition: [...]),actionreceives the changed field value as first argument and the whole form as second argument. - For
liveData, when options refresh, the target field value is reset to its default (defaultValue) or[]in multi-select mode.
Minimal combination example (select with all three):
{
elementType: 'select',
label: 'Options',
name: 'optionId',
options: [],
validation: { required: true, message: 'Required' },
renderIf: { typeId: ['*'] },
enableIf: { statusId: ['approved'] },
liveData: {
condition: ['typeId', 'statusId'],
action: async (changedValue, formData) => readOptions(changedValue, formData),
},
}Examples by use case
1. Basic modal
Use this when you just need a standard modal with fixed fields.
fields: [
{
elementType: 'input',
label: 'Name',
name: 'name',
validation: { required: true, message: 'Required' },
},
{
elementType: 'textarea',
label: 'Description',
name: 'description',
validation: { required: false },
},
];2. Render fields depending on a select
Use renderIf for mutually exclusive sections.
fields: [
{
elementType: 'select',
label: 'Mode',
name: 'mode',
defaultValue: 'email',
options: [
{ id: 'email', name: 'Email' },
{ id: 'sms', name: 'SMS' },
],
validation: { required: true, message: 'Select a mode' },
},
{
elementType: 'input',
label: 'Email',
name: 'email',
validation: { required: true, message: 'Write an email' },
renderIf: { mode: ['email'] },
},
{
elementType: 'input',
label: 'Phone',
name: 'phone',
validation: { required: true, message: 'Write a phone' },
renderIf: { mode: ['sms'] },
},
];3. Keep the field visible but disabled
Use enableIf if the user should see the field before it becomes available.
{
elementType: 'input',
label: 'Approval note',
name: 'approvalNote',
validation: { required: false },
enableIf: {
status: ['approved'],
},
}4. Load options from another field
Use liveData for dependent selects.
fields: [
{
elementType: 'select',
label: 'Type',
name: 'typeId',
options: props.typeList,
validation: {
required: true,
message: 'Please select a valid type',
},
},
{
elementType: 'select',
label: 'Options',
name: 'optionId',
options: [],
validation: {
required: true,
message: 'Please select a valid option',
},
liveData: {
condition: ['typeId'],
action: props.optionReadAction,
},
},
];5. Reserve data that should travel with the result
Use reservedData when you want to preserve contextual information without
showing it in the modal.
reservedData: {
customerId: props.customerId,
source: 'customer-profile',
}That data will be merged into the object returned by out.
6. Compose a read-only value with watcher
Use watcher when you want the modal to display a value derived from multiple
fields while the user types.
fields: [
{
elementType: 'input',
label: 'First name',
name: 'firstName',
validation: { required: true, message: 'Required' },
},
{
elementType: 'input',
label: 'Last name',
name: 'lastName',
validation: { required: true, message: 'Required' },
},
{
elementType: 'watcher',
label: 'Preview',
watchList: ['firstName', 'lastName'],
style: { width: '100%' },
},
];Important notes:
watcheris display-only- it does not submit its own value in the modal result
- it is useful for previews, concatenations, and human-readable summaries
Configuration reference
Modal-level config
Common properties of IModalConfigProps:
title: modal titlefields: list of modal elementsout: callback invoked on submitreservedData: extra data merged into the resultonClose: callback when the modal closesstyle: styles for the modal containeroverFlowBody: body height/overflow controlminHeightBody: minimum body heightuseSubmit: iffalse, action button uses manual validation modeuseBlur: enables backdrop blur stylelayout: section-level customization for:containerheader(showDivider?: boolean)titlebodyfooter(showDivider?: boolean) Each section supportsclassNameandstyle.
actions.action: main action button propsactions.cancel: optional cancel button propsactions.containerStyle: style for the action buttons container
Common field properties
Most form fields share:
namelabelplaceholderdefaultValuestylecustomPropertiesdisabledvalidationrenderIfenableIf
Most field interfaces now also accept native HTML attributes according to the
element type (input, textarea, button, select, etc.). These extra props
are forwarded with the rest of the field config.
watcher uses:
labelstylecustomPropertieswatchList
Validation supports:
requiredmessageregexmaxLengthminLengthminmax
Notes and recommendations
- Render
DynamicModalonly once per screen or page branch when possible. - Prefer stable
namevalues because they are used to manage form state. - Use
renderIffor hidden sections andenableIffor visible-but-locked sections. - Keep
liveDataactions fast and deterministic when possible. - If your custom UI components use different event contracts, adapt them inside
ComponentStaterather than changing modal configs.
Repository examples
This repository includes working examples in:
examples/simple.tsexamples/render-if.tsexamples/enable-if.tsexamples/live-data.ts
These are useful starting points for building your own modal catalog.
