Java Module 18 – Desktop GUIs with JavaFX (and Swing for legacy code)
JavaFX FXML, controllers, CSS and Scene Builder
Move a JavaFX layout into FXML with a controller, style it with JavaFX CSS and dark-mode media queries, and design screens in Scene Builder.
What you will learn
- Move layout into FXML with a controller that uses @FXML fields and handler methods
- Design an application's look with JavaFX CSS stylesheets, pseudo-classes and media queries
- Design screens in Scene Builder and review the FXML it writes like any other source file
Before you start
What you’ll learn · Before you start
What you will learn
- Move layout into FXML with a controller that uses @FXML fields and handler methods
- Design an application's look with JavaFX CSS stylesheets, pseudo-classes and media queries
- Design screens in Scene Builder and review the FXML it writes like any other source file
Before you start
On this page
JavaFX FXML is an XML file that describes a screen: its panes, its controls and which method handles which event. An
FXMLLoader reads the file, builds those nodes and creates the screen’s controller, a plain Java class whose @FXML
fields receive the nodes with a matching fx:id and whose methods handle events named like onAction="#register".
Because controllers are ordinary classes, they can be tested and reused like any other code.
CSS decides the look: JavaFX CSS rules with -fx- properties, in stylesheets added to a scene or a pane, style the
nodes by class, id and state, and since JavaFX 25 a media query can switch to a dark theme. Scene Builder, Gluon’s
open-source visual editor, lets you lay screens out by dragging and writes the FXML for you. Together they split a
screen into three files: the layout in FXML, the look in CSS and the behaviour in the controller.
From Java code to FXML and a controller
What FXMLLoader does when it loads a file
Text description of the diagram
The diagram runs from top to bottom.
"Registration.fxml", with its elements, fx:id attributes and onAction="#register", goes into FXMLLoader.load(), which works in five steps:
- it creates the controller named by fx:controller on the root element;
- it builds the nodes the elements describe;
- it injects the nodes into the controller's @FXML fields, matching each fx:id to a field name;
- it wires the handler #register to the controller's register method;
- it calls the controller's initialize() method.
The result is a scene graph and a controller, ready to show. A separate box, "Stylesheets: Modena, then yours", points at the result: the styles are applied before the next frame is drawn.
Here is a course registration form as FXML, with its controller. The controller prints when its constructor and its
initialize() method run:
Registration.fxml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.VBox?>
<!-- A course registration form: two fields, a button and a message, wired to RegistrationController. -->
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="RegistrationController" spacing="12" styleClass="form">
<padding><Insets top="16" right="16" bottom="16" left="16"/></padding>
<Label text="Course registration" styleClass="title"/>
<GridPane hgap="8" vgap="8">
<Label text="_Name" mnemonicParsing="true" labelFor="$name" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
<TextField fx:id="name" promptText="As on your ID card" GridPane.rowIndex="0" GridPane.columnIndex="1"/>
<Label text="_Branch" mnemonicParsing="true" labelFor="$branch" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
<ComboBox fx:id="branch" GridPane.rowIndex="1" GridPane.columnIndex="1"/>
</GridPane>
<Button fx:id="register" text="Register" defaultButton="true" onAction="#register"/>
<Label fx:id="message"/>
</VBox> RegistrationController.java
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
/** The controller of Registration.fxml: FXMLLoader creates it and fills the @FXML fields from the fx:id attributes. */
public class RegistrationController {
@FXML private TextField name;
@FXML private ComboBox<String> branch;
@FXML private Button register;
@FXML private Label message;
/** Whether the constructor and initialize() say when they run (LoadCost.java turns it off). */
static boolean printSteps = true;
public RegistrationController() {
if (printSteps) {
System.out.println(" constructor: name field is " + name); // nothing is injected yet
}
}
/** Called once, after every @FXML field has been injected. */
@FXML
private void initialize() {
if (printSteps) {
System.out.println(" initialize(): name field is a " + name.getClass().getSimpleName());
}
branch.getItems().setAll("Computer Engineering", "Information Technology", "Electrical Engineering");
branch.getSelectionModel().selectFirst();
register.disableProperty().bind(name.textProperty().isEmpty());
}
/** The handler that onAction="#register" names. */
@FXML
private void register() {
message.setText("Welcome " + name.getText().strip() + ", " + branch.getValue());
}
} ShowForm.java loads the file, types a name and presses Register. Each program is launched straight from its source
file: fx.args lists the module path of the JavaFX 27 SDK and the modules to load, here with javafx.fxml, and
FxRun starts the toolkit without an Application subclass:
ShowForm.java
import java.nio.file.Path;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
/** Loads Registration.fxml, prints what the loader built, then types a name and presses Register. */
public class ShowForm {
public static void main(String[] args) throws Exception {
FxRun.start();
FxRun.onFx(() -> {
try {
System.out.println("loading Registration.fxml");
FXMLLoader loader = new FXMLLoader(Path.of("Registration.fxml").toUri().toURL());
Parent root = loader.load();
System.out.println("root: " + root.getClass().getSimpleName() + " with " + root.getChildrenUnmodifiable().size() + " children; controller: " + loader.getController().getClass().getSimpleName());
Button register = (Button) loader.getNamespace().get("register"); // every fx:id is also in the namespace
System.out.println("Register disabled while the name is empty: " + register.isDisabled());
((TextField) loader.getNamespace().get("name")).setText("Kabir Mehta");
register.fire();
System.out.println("after typing and pressing Register: " + ((Label) loader.getNamespace().get("message")).getText());
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
});
Platform.exit();
}
} FxRun.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import javafx.application.Platform;
/** Starts JavaFX without an Application subclass and runs code on the JavaFX Application Thread. */
final class FxRun {
private FxRun() {
}
/** Starts the JavaFX runtime and waits until its thread is running. */
static void start() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
Platform.startup(started::countDown);
started.await();
}
/** Computes a value on the JavaFX Application Thread and returns it. */
static <T> T call(java.util.function.Supplier<T> task) throws InterruptedException {
Object[] result = new Object[1];
onFx(() -> result[0] = task.get());
@SuppressWarnings("unchecked")
T value = (T) result[0];
return value;
}
/** Runs the task on the JavaFX Application Thread and waits for it, at most 10 seconds. */
static void onFx(Runnable task) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
Platform.runLater(() -> {
try {
task.run();
} finally {
done.countDown();
}
});
if (!done.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("the JavaFX Application Thread did not answer");
}
}
} fx.args
--module-path javafx-sdk-27/lib
--add-modules javafx.controls,javafx.fxml
--enable-native-access=javafx.graphics
-Dglass.platform=headless
-Dprism.order=sw from session import javafx, run
javafx()
run("""
java @fx.args ShowForm.java
""") Output
$ java @fx.args ShowForm.java loading Registration.fxml constructor: name field is null initialize(): name field is a TextField root: VBox with 4 children; controller: RegistrationController Register disabled while the name is empty: true after typing and pressing Register: Welcome Kabir Mehta, Computer Engineering
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 form_session.py
The order matters. The loader created the controller first, when no field was set yet, then injected the nodes into
the @FXML fields by name, and only then called initialize(), which is why setup code that touches the controls
belongs there and not in the constructor. The fields are private: @FXML lets the loader reach them anyway. The
handler register is private too, and onAction="#register" found it by name. labelFor="$name" ties each label
to its field, so the keyboard mnemonic, Alt+N on Windows and Linux, moves the focus there and a screen reader
announces the label with the field.
JavaFX CSS in one stylesheet
app.css gives the form one brand colour, named once and used by name, and sets how far the Register button fades
while it is disabled:
/* The application's stylesheet: one brand colour, used by name everywhere else. */
.root {
-brand: #0b6e4f;
-fx-font-size: 14px;
}
.title {
-fx-font-size: 20px;
-fx-font-weight: bold;
}
.button {
-fx-background-color: -brand;
-fx-text-fill: white;
}
/* Modena fades disabled controls to 0.4; this form fades its buttons less, so their text stays easier to read. */
.button:disabled {
-fx-opacity: 0.6;
}
#message {
-fx-text-fill: -brand;
} import java.nio.file.Path;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.stage.Stage;
/** Loads the form, adds app.css to its scene, and prints the values CSS gave three nodes. */
public class Styled {
public static void main(String[] args) throws Exception {
FxRun.start();
FxRun.onFx(() -> {
try {
FXMLLoader loader = new FXMLLoader(Path.of("Registration.fxml").toUri().toURL());
Parent root = loader.load();
Scene scene = new Scene(root, 360, 240);
scene.getStylesheets().add(Path.of("app.css").toUri().toString());
Stage stage = new Stage();
stage.setScene(scene);
stage.show();
root.applyCss(); // CSS normally runs before the next frame; this applies it now
Label title = (Label) root.lookup(".title");
Button register = (Button) loader.getNamespace().get("register");
Label message = (Label) loader.getNamespace().get("message");
System.out.println("title: " + title.getFont().getSize() + " px, " + title.getFont().getStyle());
System.out.println("register: background " + register.getBackground().getFills().get(0).getFill()
+ ", text " + register.getTextFill() + ", disabled " + register.isDisabled() + ", opacity " + register.getOpacity());
((TextField) loader.getNamespace().get("name")).setText("Kabir Mehta");
root.applyCss();
System.out.println("with a name typed: disabled " + register.isDisabled() + ", opacity " + register.getOpacity());
System.out.println("message text colour: " + message.getTextFill());
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
});
Platform.exit();
}
} from session import javafx, run
javafx()
run("""
java @fx.args Styled.java
""") Output
$ java @fx.args Styled.java constructor: name field is null initialize(): name field is a TextField title: 20.0 px, Bold register: background 0x0b6e4fff, text 0xffffffff, disabled true, opacity 0.6 with a name typed: disabled false, opacity 1.0 message text colour: 0x0b6e4fff
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 styled_session.py
JavaFX CSS looks like web CSS, with differences that matter: properties carry the -fx- prefix, -brand is a
looked-up colour that any rule below .root can use by name, and pseudo-classes describe a node’s state, here
:disabled. Every application starts from Modena, the stylesheet that ships with JavaFX, and your stylesheets add
to it: the disabled button got the 0.6 of app.css rather than the 0.4 that Modena gives disabled controls. Once a
name was typed and the binding enabled the button, the rule stopped matching and the opacity went back to 1.0,
without a line of Java code.
Scene Builder, and when FXML pays off
Scene Builder 26.0.0, the release on Gluon’s download page, is an open-source editor under a BSD licence, with installers for Windows, macOS and Linux: you drag panes and controls onto a canvas, set their properties and fx:ids, attach stylesheets, and it saves FXML. The FXML it writes is source code, and it deserves the same review as Java code. FXML also has a cost, as this measurement shows:
import java.net.URL;
import java.nio.file.Path;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
/** What FXML costs: the same form loaded from Registration.fxml and built in Java code, first time and on average. */
public class LoadCost {
static Parent inCode() {
TextField name = new TextField();
ComboBox<String> branch = new ComboBox<>();
GridPane grid = new GridPane(8, 8);
grid.addRow(0, new Label("_Name"), name);
grid.addRow(1, new Label("_Branch"), branch);
VBox form = new VBox(12, new Label("Course registration"), grid, new Button("Register"), new Label());
form.setPadding(new Insets(16));
return form;
}
public static void main(String[] args) throws Exception {
URL fxml = Path.of("Registration.fxml").toUri().toURL();
RegistrationController.printSteps = false;
FxRun.start();
FxRun.onFx(() -> {
try {
long t = System.nanoTime();
new FXMLLoader(fxml).load();
long firstFxml = System.nanoTime() - t;
t = System.nanoTime();
inCode();
long firstCode = System.nanoTime() - t;
int n = 200;
t = System.nanoTime();
for (int i = 0; i < n; i++) {
new FXMLLoader(fxml).load();
}
double fxmlEach = (System.nanoTime() - t) / 1e6 / n;
t = System.nanoTime();
for (int i = 0; i < n; i++) {
inCode();
}
double codeEach = (System.nanoTime() - t) / 1e6 / n;
System.out.printf("first time: FXML %d ms, Java code %d ms%n", firstFxml / 1_000_000, firstCode / 1_000_000);
System.out.printf("then, on average over %d: FXML %.2f ms, Java code %.2f ms%n", n, fxmlEach, codeEach);
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
});
Platform.exit();
}
} from session import javafx, run
javafx()
run("""
javac --module-path javafx-sdk-27/lib --add-modules javafx.controls,javafx.fxml -d out LoadCost.java FxRun.java RegistrationController.java
java @fx.args -cp out LoadCost
""") Output
$ javac --module-path javafx-sdk-27/lib --add-modules javafx.controls,javafx.fxml -d out LoadCost.java FxRun.java RegistrationController.java $ java @fx.args -cp out LoadCost first time: FXML 573 ms, Java code 7 ms then, on average over 200: FXML 14.08 ms, Java code 0.32 ms
This output changes from run to run: The times change from run to run with the load on the computer.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cost_session.py
| JavaFX FXML with a controller | Java code | |
|---|---|---|
| Layout | readable as a tree; edited in Scene Builder | mixed with behaviour unless you keep them apart |
| Mistakes in names | found when the file is loaded | found by the compiler |
| Cost in this run | several hundred ms the first time, 10 to 15 ms per load after | a few ms the first time, under 1 ms after |
| Screens built at run time from data | awkward | natural |
The first FXML load of a run paid for loading the FXML machinery itself; later loads of the same form cost 10 to 15
milliseconds each, against well under one millisecond in Java code. That is nothing for a screen a user opens,
and too much for a row of a list repeated hundreds of times. A controller factory lets the loader build controllers
that need arguments, such as a service. fx:include reuses one FXML file inside another, with its own controller.
Exporting and opening a package to javafx.fxml lets FXML work in a named module.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Controllers in depth, with the failures reproduced Pro: opens with a Pro, Premium or Ultimate pass
FXML moves mistakes from compile time to load time, so knowing what each mistake prints saves hours. This part reproduces six of them, three module setups, controller factories with included files and translated texts, and ends with an exercise.
In this part:
- Six FXML mistakes and the messages they print
- Modules: exports, opens and the three messages
- Controller factories, included files and translated texts
- Your turn: move a hand-coded screen into FXML
9 runnable examples (Address.fxml, AddressController.java, CheckFee.java, FeeController.java, FeeForm.fxml, Fees.java, HandCoded.java, HelloController.java, Java, Main.java, Profile.fxml, ProfileController.java, Python, ShowProfile.java, fee.css, messages_hi.properties, module-info-closed.java, module-info-exported.java, module-info-open.java, starter/FeeController.java, starter/FeeForm.fxml) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Styling with CSS: the cascade, themes and dark mode, measured Pro: opens with a Pro, Premium or Ultimate pass
Most styling bugs come from not knowing which rule wins, and most theme bugs from colours spread across many rules. This part measures the cascade, builds light and dark themes with checked contrast, and reviews FXML that a visual editor wrote.
In this part:
- Which rule wins: six sources for one colour, measured
- Light and dark themes with contrast you can check
- One theme per file with conditional imports
- Reviewing what a visual editor writes
8 runnable examples (Cascade.java, FxmlReview.java, Imports.java, Python, Themes.java, css/dark.css, css/important.css, css/light.css, css/parent.css, css/scene.css, css/theme.css, css/themes-import.css, review/Enquiry.fxml, review/EnquiryController.java) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What does FXMLLoader do with fx:controller, fx:id and onAction="#name" when it loads an FXML file?
It builds the nodes the elements describe and creates an instance of the class named by fx:controller on the root element. For every element with an fx:id it stores the node in the loader's namespace, sets the node's id, and injects it into the controller field with the same name, which may be private if it is annotated with @FXML. An attribute such as onAction="#register" is wired to the controller method of that name, again private if it has @FXML. When the whole document is loaded and injected, the loader calls the controller's initialize() method, so setup that uses the controls goes there, not into the constructor, which runs before any field is set. A wrong handler name or type fails at load time, while an fx:id without a matching field just leaves the field null.
-
Mid to senior
A JavaFX app with FXML screens runs from the IDE on the class path. Packaged as a named module, it fails when the first screen loads. A colleague proposes making every @FXML field and handler public. Explain the errors you can expect, the right fix, and why the proposal is wrong.
-
Mid to senior
To flag an invalid field, a developer calls field.setStyle("-fx-text-fill: red") in one screen and label.setTextFill(Color.RED) in another. After a designer adds a theme stylesheet, the second one stops working and the first one breaks the dark theme. Explain the JavaFX CSS cascade and redesign the error state.
-
Senior to staff
A 40-screen JavaFX app needs a light theme, a dark theme that follows the operating system, and a high-contrast theme users can pick, all without changing screen code. Design where colours live, how themes switch, and how you keep every theme readable as screens change.
Key takeaways
- FXML holds the layout, CSS the look and the controller the behaviour.
- The loader injects
@FXMLfields by fx:id, wires#handlernames and then callsinitialize(). - JavaFX CSS uses
-fx-properties, looked-up colours and pseudo-classes such as:disabled. - Scene Builder writes FXML; review it like code.
- FXML costs a load at run time; for a screen a user opens, that cost does not matter.
References
- Introduction to FXML (JavaFX 27) (OpenJFX)
- FXMLLoader (JavaFX 27) (OpenJFX)
- JavaFX CSS Reference Guide (JavaFX 27) (OpenJFX)
- PseudoClass (JavaFX 27) (OpenJFX)
- Scene.Preferences (JavaFX 27) (OpenJFX)
- Release Notes for JavaFX 25 (OpenJDK)
- Release Notes for JavaFX 27 (OpenJDK)
- Scene Builder (Gluon)
- Web Content Accessibility Guidelines (WCAG) 2.2: contrast (minimum) and relative luminance (W3C)
- ResourceBundle (Java SE 25 API) (Oracle)
- PropertyResourceBundle (Java SE 25 API) (Oracle)
- JEP 226: UTF-8 Property Resource Bundles (OpenJDK)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress