Skip to content
CalliCoder

Creating a Registration Form in JavaFX

Published Updated JavaFX 13 min read

GridPane against VBox for a labelled form, the column constraints that make a field stretch, validating as the user types rather than on submit, and why a modal dialog needs an owner.

A registration form is the smallest interface that needs every part of a layout system: aligned labels, fields that stretch, a button row that does not, and validation that has to appear somewhere without moving everything else. Built in Java rather than FXML, which makes each decision explicit.

Written against JavaFX 21 and Java 17.

GridPane, and why not VBox

A form is a two-column grid (labels on the left, controls on the right) and the labels have to align with each other. VBox cannot do that: each row would be an HBox sizing independently, so the fields start at different x positions unless every label is given the same fixed width.

GridPane grid = new GridPane();
grid.setHgap(10);
grid.setVgap(12);
grid.setPadding(new Insets(24));
grid.setAlignment(Pos.CENTER);

add(node, column, row) places a node, and the argument order is column-then-row, which is the opposite of how most people say it:

TextField nameField = new TextField();
TextField emailField = new TextField();
PasswordField passwordField = new PasswordField();

grid.add(new Label("Name"), 0, 0);
grid.add(nameField, 1, 0);
grid.add(new Label("Email"), 0, 1);
grid.add(emailField, 1, 1);
grid.add(new Label("Password"), 0, 2);
grid.add(passwordField, 1, 2);

Making the fields stretch

By default a GridPane column is as wide as its widest child and does not grow with the window. Column constraints fix that:

ColumnConstraints labels = new ColumnConstraints();
labels.setHalignment(HPos.RIGHT);
labels.setMinWidth(80);

ColumnConstraints fields = new ColumnConstraints();
fields.setHgrow(Priority.ALWAYS);       // absorbs the extra width
fields.setFillWidth(true);

grid.getColumnConstraints().addAll(labels, fields);

Priority.ALWAYS on exactly one column is the usual arrangement, the label column keeps its natural width and the field column takes everything left over. Setting it on both splits the space evenly, which looks wrong for a form.

A node can also be told to grow individually, which is the shorter form for a one-off:

GridPane.setHgrow(emailField, Priority.ALWAYS);

grid.setGridLinesVisible(true) draws the cell boundaries. It is a debugging tool rather than a feature, and it is by far the fastest way to see why something is in the wrong cell.

Spanning and the button row

Button submit = new Button("Register");
Button cancel = new Button("Cancel");

HBox buttons = new HBox(10, cancel, submit);
buttons.setAlignment(Pos.CENTER_RIGHT);

grid.add(buttons, 1, 3);
grid.add(statusLabel, 0, 4, 2, 1);   // column, row, colspan, rowspan

Buttons belong together in an HBox rather than in separate grid cells, because their spacing is relative to each other rather than to the form’s columns. Right-aligning them and putting the primary action last matches the platform convention on Windows and Linux; macOS is the same order.

submit.setDefaultButton(true);   // fires on Enter
cancel.setCancelButton(true);    // fires on Escape

Those two lines are the difference between a form that can be completed entirely from the keyboard and one that cannot, and neither costs anything to add.

Validating as the user types

Validating only on submit means the user finds out about a mistake after finishing. A listener on the text property reports immediately:

BooleanBinding invalid = Bindings.createBooleanBinding(
        () -> nameField.getText().isBlank()
           || !emailField.getText().contains("@")
           || passwordField.getText().length() < 8,
        nameField.textProperty(), emailField.textProperty(), passwordField.textProperty());

submit.disableProperty().bind(invalid);

The button disables itself and re-enables when everything is valid, with no handler involved. The properties passed after the lambda are the dependencies. Omitting one means that field’s changes do not re-evaluate the binding, which is a silent bug rather than an error.

A disabled button with no explanation is unhelpful, so pair it with a message:

emailField.textProperty().addListener((obs, old, value) -> {
    boolean bad = !value.isEmpty() && !value.contains("@");
    emailField.pseudoClassStateChanged(PseudoClass.getPseudoClass("error"), bad);
    statusLabel.setText(bad ? "Enter a valid email address" : "");
});
.text-field:error {
    -fx-border-color: #c0392b;
    -fx-border-width: 2;
}

A pseudo-class is the right mechanism because the styling stays in the stylesheet rather than being hard-coded as an inline style that then has to be removed again.

Reserving space for the message

The mistake that makes a form feel unstable: showing an error label only when there is an error, so every row below it jumps.

statusLabel.setMinHeight(Region.USE_PREF_SIZE);   // keep the row's height even when empty

Setting the text to an empty string rather than calling setVisible(false) has the same effect — an invisible node still occupies its space, whereas setManaged(false) removes it from the layout entirely, which is what causes the jump.

Restricting input

A TextFormatter filters keystrokes before they reach the field, which is better than correcting the text afterwards:

TextField ageField = new TextField();
ageField.setTextFormatter(new TextFormatter<>(change ->
        change.getControlNewText().matches("\\d{0,3}") ? change : null));

Returning null rejects the change, so a non-digit is never inserted and the caret does not move. Doing the same with a listener that rewrites the text works and fights the user, because the caret jumps to the end on every correction.

getControlNewText() is the text as it would be, which is what makes the length limit work — checking change.getText() only sees the inserted characters, so a paste that exceeds the limit passes and a backspace is judged against nothing.

TextFormatter also takes a converter, which is what turns the field’s text into a typed value:

ageField.setTextFormatter(new TextFormatter<>(new IntegerStringConverter(), 0, filter));
Integer age = (Integer) ageField.getTextFormatter().getValue();

The value updates on commit, Enter or focus loss, rather than on every keystroke, which is usually what a form wants and occasionally is not.

Keyboard order and accessibility

Tab order follows the order nodes were added to their parent, which for a GridPane built row by row is already correct. It stops being correct the moment a field is added out of order or moved, and nothing about the visual layout reveals the mismatch: the form simply feels wrong to anyone not using a mouse.

nameField.setFocusTraversable(true);
Platform.runLater(nameField::requestFocus);   // focus the first field on open

requestFocus before the scene is shown does nothing, which is why it is wrapped in runLater: the call has to happen after the window exists.

Labels should also be associated with their controls, so a screen reader announces the right name:

Label emailLabel = new Label("Email");
emailLabel.setLabelFor(emailField);

setLabelFor additionally enables mnemonics: new Label("_Email") with setMnemonicParsing(true) then focuses the field on Alt+E. Both are two lines that a form is noticeably worse without.

Sizing the window to the content

Scene scene = new Scene(grid);
stage.setScene(scene);
stage.sizeToScene();
stage.setMinWidth(360);
stage.show();

Constructing the Scene without explicit dimensions sizes it to the preferred size of its root, which for a GridPane is the sum of its rows and columns plus the gaps. That is almost always the right initial size for a form.

A minimum width is worth setting anyway: GridPane will happily shrink below the point where the labels are readable, clipping them rather than refusing. There is no automatic floor, and no warning when the content stops fitting.

Submitting, and the dialog

submit.setOnAction(event -> {
    Alert alert = new Alert(Alert.AlertType.INFORMATION);
    alert.initOwner(submit.getScene().getWindow());
    alert.setTitle("Registered");
    alert.setHeaderText(null);
    alert.setContentText("Welcome, " + nameField.getText().trim());
    alert.showAndWait();
});

initOwner is the line people leave out. Without an owner the dialog is a separate top-level window: it can appear behind the form, it gets its own taskbar entry, and it is not modal to anything.

showAndWait blocks until the dialog closes and must be called on the FX thread. It is one of the few JavaFX calls that blocks that thread deliberately, by running a nested event loop. show() returns immediately, which is what you want for a non-blocking notification.

Related: the application skeleton and the same form in FXML.

Frequently asked questions

GridPane or VBox for a form?

GridPane. A form needs labels aligned across rows, which a stack of HBox rows cannot do without fixing every label to the same width.

What is the argument order of GridPane.add?

add(node, column, row): column first, which is the reverse of how most people describe a position.

Why do my text fields not stretch with the window?

A grid column does not grow by default. Give the field column a ColumnConstraints with setHgrow(Priority.ALWAYS).

How do I make a node span two columns?

The five-argument add(node, column, row, colspan, rowspan).

How do I see why a node is in the wrong place?

grid.setGridLinesVisible(true) draws the cell boundaries. It is the fastest layout debugging tool JavaFX has.

How do I enable Enter and Escape on a form?

setDefaultButton(true) on the primary button and setCancelButton(true) on cancel.

How do I disable the submit button until the form is valid?

Bind disableProperty to a BooleanBinding over the field properties. Every property the rule reads must be passed as a dependency, or its changes will not re-evaluate it.

Why does my layout jump when an error appears?

The message node is being added or removed from the layout. Keep it present with an empty string, and set setMinHeight(Region.USE_PREF_SIZE).

How do I restrict a field to numbers?

A TextFormatter whose filter returns null for a rejected change. Rewriting the text in a listener works and moves the caret on every keystroke.

Why does my dialog appear behind the window?

No owner was set. alert.initOwner(window) makes it modal to the form and keeps it in front.