Most Modern UI work is composing existing qp-* layout components — you rarely need to build a genuinely custom control. But every so often a requirement needs something the standard field editors don't offer: a signature pad, a color swatch picker, a specialized numeric input with a client-specific mask. Here's what actually building one of those looks like in the FrontendSources TypeScript project, using a signature-capture control I built for a delivery-confirmation screen as the running example.
Where custom controls live in the project
Modern UI screens are built in a TypeScript project under FrontendSources, compiled with the platform's npm/webpack build into the bundles the browser actually loads. A custom control is a TypeScript class extending the framework's base editor/control class, registered so screen layout markup can reference it by tag name:
import { PXFieldOptions, PXFieldState, createRef } from "client-controls";
import { PXFieldControlBase } from "client-controls/component/px-field-control-base";
export interface SignaturePadOptions extends PXFieldOptions {
penColor?: string;
}
export class PXSignaturePad extends PXFieldControlBase<SignaturePadOptions, PXFieldState> {
private canvasRef = createRef<HTMLCanvasElement>();
render() {
return (
<canvas
ref={this.canvasRef}
width={400}
height={150}
onPointerDown={this.startStroke}
onPointerMove={this.drawStroke}
onPointerUp={this.commitToField}
/>
);
}
private commitToField = () => {
const dataUrl = this.canvasRef.current.toDataURL("image/png");
this.updateValue(dataUrl); // pushes into the bound view field
};
}
That updateValue call is the important line — it's what connects your custom rendering back into the same value-and-state pipeline every stock control uses, so the field you're bound to participates normally in the screen's save, validation, and dirty-tracking behavior without you reimplementing any of that plumbing yourself.
Registering the control and referencing it from a screen
Once built and compiled into the bundle, the control needs registering with the framework's component factory, and then a screen's TypeScript definition references it on a field the same way it would reference a stock PXTextEdit or PXSelector:
@graphInfo({ graphType: "MyNamespace.Graphs.DeliveryConfirmEntry", primaryView: "Document" })
export class DeliveryConfirmScreen extends PXScreen {
Document = createSingle(DeliveryDocument);
}
// In the layout, the field's editor is declared via the control's tag:
// <qp-signature-pad value={Document.SignatureData} />
The DAC-side field this binds to is nothing special — a plain PXDBLongString or file-reference field on the extension, exactly what you'd use for any other unbound or bound value. The custom control is purely a presentation-layer concern; it doesn't change anything about how you'd model the field server-side.
Respect the framework's state lifecycle, don't fight it
The most common mistake I see (and made myself, the first time) is a custom control holding its own private state disconnected from the framework's field state — the canvas draws fine, but the value never actually reaches the bound view field until an explicit save action, so a screen-level Cancel or a tab-away doesn't discard the in-progress signature the way it would for a stock text field. The fix is discipline about calling the framework's state-update methods on every meaningful interaction, not just on an explicit "done" button, so the control participates in dirty-tracking and undo the same way a native control does.
A custom control needs to visibly honor disabled, read-only, and required states exactly like a stock control — because Acumatica's server-side attribute logic (PXUIField's Enabled/Required properties, RowSelected-driven conditional states) expects every field editor to respect those states uniformly. A custom control that ignores the disabled flag it's handed will happily let a user "sign" a field the business logic explicitly locked, which is a real bug I've caught in code review more than once.
Before building a custom control, exhaust composition of existing ones
Building and maintaining a genuinely custom control is real ongoing cost — it needs updating when the platform's control base classes change across versions, unlike declarative layout which mostly keeps working across upgrades unchanged. Before reaching for a custom control, check whether composing existing qp-* components (a masked text input, a rich rendering template on a grid column, a custom formatter function on a stock editor) gets you close enough. I only build a genuinely new control when the interaction model itself is novel — like signature capture or a specialized drawing surface — not when it's really a formatting or validation variation on an existing input type.
Wrapping up
A Modern UI custom control is a TypeScript class in FrontendSources that extends the framework's field control base, renders its own markup, and pushes values back through the standard state-update pipeline so it behaves like any native editor for save, validation, and enabled/required states. Reserve it for genuinely novel interactions, wire state updates on every meaningful change rather than just a final commit, and test disabled/required/read-only behavior explicitly — those are the details that separate a control that feels native from one that quietly breaks the screen's existing business rules.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.