JavaFX Binding, Events & Styling

Custom Controls & Reuse

18 min Lesson 8 of 12

Custom Controls & Reuse

JavaFX ships with a rich library of built-in controls, but real applications almost always need something the library does not provide out of the box: a labelled spinner, a card widget with a header and body, a reusable form row that ties a label to its field and its validation message. This lesson teaches you the mechanics and design decisions involved in building such components so that they can be dropped into any scene just like a Button or a TextField.

Two Approaches: Composition vs. Subclassing

Before writing any code you need to decide on your implementation strategy. JavaFX gives you two clean options.

  • Composition (preferred): Extend an existing layout pane (HBox, VBox, StackPane, …) and add child nodes inside its constructor. The result is a pane that you can place anywhere a Node is accepted.
  • Subclassing a Control: Extend Control and pair it with a Skin implementation. This is the correct path for complex, skinnable widgets, but it requires more boilerplate.

For the majority of day-to-day custom widgets, composition is the right answer. You get layout behaviour for free and can focus entirely on the component's API and logic.

Building a Reusable LabeledField Component

A LabeledField is a very common requirement: a label above (or beside) a text field, plus an optional error message below it — all three nodes moving together as one unit.

import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.scene.control.Label; import javafx.scene.control.TextField; import javafx.scene.layout.VBox; public class LabeledField extends VBox { private final Label label = new Label(); private final TextField field = new TextField(); private final Label errorLabel = new Label(); // JavaFX properties so callers can bind to them private final StringProperty labelText = new SimpleStringProperty(this, "labelText", ""); private final StringProperty errorText = new SimpleStringProperty(this, "errorText", ""); public LabeledField(String labelText) { setSpacing(4); getStyleClass().add("labeled-field"); label.textProperty().bind(this.labelText); errorLabel.textProperty().bind(errorText); errorLabel.getStyleClass().add("field-error"); errorLabel.setVisible(false); errorLabel.setManaged(false); // Show the error label only when the text is non-empty errorText.addListener((obs, old, val) -> { boolean hasError = val != null && !val.isEmpty(); errorLabel.setVisible(hasError); errorLabel.setManaged(hasError); }); getChildren().addAll(label, field, errorLabel); setLabelText(labelText); } // --- property accessors ------------------------------------------------- public StringProperty labelTextProperty() { return labelText; } public String getLabelText() { return labelText.get(); } public void setLabelText(String t) { labelText.set(t); } public StringProperty errorTextProperty() { return errorText; } public String getErrorText() { return errorText.get(); } public void setErrorText(String t) { errorText.set(t); } /** Delegate so callers can bind to the underlying TextField value. */ public StringProperty textProperty() { return field.textProperty(); } public String getText() { return field.getText(); } public void setText(String t) { field.setText(t); } /** Give the internal field a prompt. */ public void setPrompt(String prompt) { field.setPromptText(prompt); } }
Expose JavaFX properties, not just getters/setters. By wrapping labelText and errorText as StringProperty fields and returning them from labelTextProperty(), callers can use the full binding API — including Bindings.when(…) expressions and listeners — exactly as they would with any built-in control.

Using the Component in a Scene

Once the class exists you use it like any other node:

LabeledField emailField = new LabeledField("Email address"); emailField.setPrompt("you@example.com"); emailField.setMaxWidth(300); Button submitBtn = new Button("Submit"); submitBtn.setOnAction(e -> { if (!emailField.getText().contains("@")) { emailField.setErrorText("Please enter a valid email address."); } else { emailField.setErrorText(""); System.out.println("Email: " + emailField.getText()); } }); VBox root = new VBox(12, emailField, submitBtn); root.setPadding(new Insets(20));

Loading FXML Into a Custom Control

For controls with complex layouts, maintaining the structure in an FXML file is far cleaner than constructing the scene graph in pure Java. The pattern is to load the FXML inside the component's own constructor, with the component acting as both the root and the controller.

// resources/com/example/controls/StatusCard.fxml // <?xml version="1.0" encoding="UTF-8"?> // <fx:root type="VBox" xmlns:fx="http://javafx.com/fxml"> // <Label fx:id="titleLabel" styleClass="card-title"/> // <Label fx:id="statusLabel" styleClass="card-status"/> // </fx:root> import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.control.Label; import javafx.scene.layout.VBox; import java.io.IOException; public class StatusCard extends VBox { @FXML private Label titleLabel; @FXML private Label statusLabel; public StatusCard() { FXMLLoader loader = new FXMLLoader( getClass().getResource("/com/example/controls/StatusCard.fxml")); loader.setRoot(this); // <fx:root> maps to this instance loader.setController(this); // @FXML fields injected into this try { loader.load(); } catch (IOException ex) { throw new RuntimeException("Cannot load StatusCard.fxml", ex); } } public void setTitle(String title) { titleLabel.setText(title); } public void setStatus(String status) { statusLabel.setText(status); } }
Use <fx:root> — not <VBox> — as the root element in the FXML file. This is the signal to FXMLLoader that the root object is provided externally (via setRoot(this)). Without it the loader creates a second VBox instance and your @FXML fields never get injected into the component.

Exposing a CSS API

A well-designed control is styleable via CSS without requiring source changes. There are two levels of support you should provide.

  1. Style classes: Assign meaningful style-class strings so users can target your component in a stylesheet.
    getStyleClass().add("labeled-field"); label.getStyleClass().add("labeled-field-label"); field.getStyleClass().add("labeled-field-input"); errorLabel.getStyleClass().add("labeled-field-error");
  2. CSS pseudo-classes: For states like invalid, use PseudoClass so the control responds to :invalid in CSS.
    import javafx.css.PseudoClass; private static final PseudoClass INVALID = PseudoClass.getPseudoClass("invalid"); // toggle it when the error changes errorText.addListener((obs, old, val) -> { boolean hasError = val != null && !val.isEmpty(); errorLabel.setVisible(hasError); errorLabel.setManaged(hasError); pseudoClassStateChanged(INVALID, hasError); });
    In the application stylesheet:
    .labeled-field:invalid .labeled-field-input { -fx-border-color: #e53935; }

Keeping Controls Reusable: Design Guidelines

  • Encapsulate layout, expose data. Internal layout decisions (spacing, padding, node structure) should not leak. Callers should only see a clean property API.
  • Never hard-code dimensions inside the component. Let the parent layout or the CSS control sizing. Use setMaxWidth(Double.MAX_VALUE) on child nodes you want to grow with the container.
  • Prefer ObjectProperty / StringProperty / BooleanProperty over plain fields. This enables binding and makes the component work naturally with the rest of JavaFX.
  • Provide a no-argument constructor in addition to convenience constructors. FXML cannot invoke constructors that take parameters unless you use @NamedArg, so a default constructor keeps the component usable from FXML.
Do not access @FXML-injected fields in the constructor body — use @FXML initialize() instead. When the FXML loader calls your constructor the injected fields are null; they are populated only after the XML is parsed. Set up bindings and listeners that reference those fields inside the initialize() method, which the loader calls once injection is complete.

Packaging Controls for Reuse Across Projects

Once you have several custom controls it is worth packaging them as a standalone JAR (or a Java module). Place each control class and its FXML resource in the same package. In a modular project, expose the package in module-info.java:

module com.example.controls { requires javafx.controls; requires javafx.fxml; exports com.example.controls; opens com.example.controls to javafx.fxml; // lets FXMLLoader access private fields }

The opens … to javafx.fxml directive is mandatory because FXMLLoader uses reflection to inject @FXML-annotated fields, and the module system blocks reflective access by default.

Summary

Custom controls are built by extending a layout pane (composition) or Control (when full skinning is needed), exposing a property-based API, loading FXML with the <fx:root> pattern when the layout is complex, and offering CSS hooks via style classes and pseudo-classes. The result is a node that is indistinguishable — from the scene graph's perspective — from any built-in JavaFX control, and that any screen in the application can reuse without duplication.