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.