Acumatica · Modernui

Acumatica Modern UI Custom Control — A Complete Guide

Acumatica Modern UI Custom Control — A Complete Guide is one of those Acumatica topics that is both obvious and subtle.

John Kihiu12 min read

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:

TYPESCRIPT
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:

TYPESCRIPT
@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.

Test with the platform's built-in field states, not just the happy path

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.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.