Skip to content

Responsive Form Grid

Build complex, responsive forms without manually writing <Row><Col>...</Col></Row> boilerplate, with built-in conditional field visibility.

What Problem This Solves

You need forms that:

  • Automatically layout fields in responsive columns (mobile: 1 col, desktop: 2-3 cols)
  • Support multiple input types (text, select, date, numeric) via a declarative API
  • Show/hide fields based on other field values
  • Work as both regular forms and filter panels

Prerequisites

  • antd
  • react-number-format (for numeric inputs)

Files

responsive-col.tsx

tsx
import { Col, type ColProps } from "antd";

export const ResponsiveCol = (props: ColProps & { fullWidth?: boolean }) => {
  const { fullWidth, ...colProps } = props;
  return (
    <Col
      xs={24}
      md={fullWidth ? 24 : 12}
      lg={fullWidth ? 24 : 8}
      xl={fullWidth ? 24 : 6}
      {...colProps}
    />
  );
};

input-item.tsx

tsx
import { Form, type FormItemProps } from "antd";
import { ResponsiveCol } from "./responsive-col";

export type InputItemProps = FormItemProps & {
  fullWidth?: boolean;
};

export const InputItem = ({ fullWidth, children, ...props }: InputItemProps) => (
  <ResponsiveCol fullWidth={fullWidth}>
    <Form.Item {...props}>{children}</Form.Item>
  </ResponsiveCol>
);

input-grid.tsx

tsx
import { Row, Skeleton } from "antd";
import { lazy, Suspense } from "react";
import { InputItem } from "./input-item";

const Input = lazy(() => import("antd").then((m) => ({ default: m.Input })));
const Select = lazy(() => import("antd").then((m) => ({ default: m.Select })));
const DatePicker = lazy(() => import("antd").then((m) => ({ default: m.DatePicker })));
const NumericFormatInput = lazy(() => import("./numeric-format-input"));

const inputComponentMap = {
  text: Input,
  select: Select,
  datePicker: DatePicker,
  numeric: NumericFormatInput,
} as const;

export type InputDefinition = {
  inputType: keyof typeof inputComponentMap;
  name: string;
  label: string;
  inputProps?: Record<string, unknown>;
  fullWidth?: boolean;
  rules?: Array<Record<string, unknown>>;
};

type InputGridProps = {
  inputs: InputDefinition[];
};

export const InputGrid = ({ inputs }: InputGridProps) => {
  return (
    <Row gutter={[16, 16]}>
      <Suspense fallback={<Skeleton active />}>
        {inputs.map((def) => {
          const Component = inputComponentMap[def.inputType];
          return (
            <InputItem
              key={def.name}
              name={def.name}
              label={def.label}
              rules={def.rules}
              fullWidth={def.fullWidth}
            >
              <Component {...(def.inputProps ?? {})} />
            </InputItem>
          );
        })}
      </Suspense>
    </Row>
  );
};

numeric-format-input.tsx

tsx
import { Input } from "antd";
import { NumericFormat } from "react-number-format";

export const NumericFormatInput = (props: React.ComponentProps<typeof NumericFormat>) => (
  <NumericFormat customInput={Input} thousandSeparator="," decimalSeparator="." {...props} />
);

Usage

tsx
import { Form, Button } from "antd";
import { InputGrid } from "@/lib/components/data-entry/input-grid";

const userFormInputs = [
  { inputType: "text" as const, name: "name", label: "Full Name", rules: [{ required: true }] },
  {
    inputType: "text" as const,
    name: "email",
    label: "Email",
    rules: [{ required: true, type: "email" }],
  },
  {
    inputType: "select" as const,
    name: "role",
    label: "Role",
    inputProps: { options: roleOptions },
  },
  { inputType: "datePicker" as const, name: "birthDate", label: "Birth Date" },
  { inputType: "numeric" as const, name: "salary", label: "Salary", fullWidth: true },
];

export function UserForm() {
  const [form] = Form.useForm();

  return (
    <Form form={form} onFinish={(values) => console.log(values)}>
      <InputGrid inputs={userFormInputs} />
      <Button type="primary" htmlType="submit">
        Save
      </Button>
    </Form>
  );
}

Conditional Visibility

For fields that show/hide based on other values, wrap InputGrid and use Form.useWatch:

tsx
export function ConditionalForm() {
  const [form] = Form.useForm();
  const role = Form.useWatch("role", form);

  const inputs = [
    { inputType: "text", name: "name", label: "Name" },
    ...(role === "admin" ? [{ inputType: "text", name: "adminCode", label: "Admin Code" }] : []),
  ];

  return (
    <Form form={form}>
      <InputGrid inputs={inputs} />
    </Form>
  );
}

Variations

As Filter Panel

Use the same InputGrid for table filters by adding paramKey to each input definition and wiring to handleUpdateFilter from Table with Synced Pagination.

With antd-style CSS-in-JS

If using antd-style (Emotion-based), wrap InputGrid in a styled container for consistent spacing.