If you have a Swing application and would like to bring it to the web, you have had two ways to do that. Either take on a rewrite project with your favorite web framework or stream it to the web using solutions like SwingBridge Streamer.
Now we are giving you a third option, SwingBridge Emulators. They allow you to migrate to Vaadin mostly by mechanical import statement swap, making the migration quick with AI assistance (skills and guardrails included).
The Emulators are released under open source license, allowing you to use them freely in your migration project. Read on for more information on how they work and an example run on a third-party application!
How it works
vaadinx.awt.* mirrors java.awt.* for the Component and Container hierarchy, and vaadinx.swing.* mirrors javax.swing.* for the JComponent subclasses. Every vaadinx.awt.Component holds a real Vaadin component as its peer and forwards behavior to it.
Here is a view from the bundled CRUD example, shortened. The imports change; the body does not.
// before
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import java.awt.BorderLayout;
// after
import vaadinx.swing.BorderFactory;
import vaadinx.swing.BoxLayout;
import vaadinx.swing.JLabel;
import vaadinx.swing.JPanel;
import vaadinx.awt.BorderLayout;
public final class EmployeePreviewPanel extends JPanel {
private final JLabel nameValue = new JLabel(EMPTY);
// …
public EmployeePreviewPanel() {
super(new BorderLayout());
setBorder(BorderFactory.createTitledBorder("Preview"));
JPanel rows = new JPanel();
rows.setLayout(new BoxLayout(rows, BoxLayout.Y_AXIS));
rows.add(row("Name", nameValue));
// …
add(rows, BorderLayout.NORTH);
}
}
Over forty Swing components are emulated, along with six layout managers, Action routing, keyboard bindings, focus traversal, Timer and SwingWorker, clipboard and drag-and-drop.
When a method has no Vaadin counterpart it logs a WARN and returns a default instead of throwing. About 450 methods do this. The app keeps running, but a default can be the wrong answer, and each warning is something to check. Genuine programming errors still throw what Swing would throw.
Why an emulator
Stream it. SwingBridge Streamer runs your unmodified app on the server and streams the rendered UI to the browser. It needs no source access and no per-component coverage, so third-party toolkits and custom painting simply work. What you still own afterwards is a Swing application.
Rewrite it. Build a fresh Vaadin app and you get idiomatic code immediately. You also have to reproduce twenty years of edge cases from a specification, and behavior the specification misses can change without anyone deciding to change it.
Port it. The emulators sit between those. The transform is mechanical, and the time goes into checking the result against the running desktop app. The emulators aim to reproduce Swing's contract, bugs included, with no deliberate improvements. Where the emulation is incomplete, behavior can differ without an error.
Because the ported sources stay line-for-line close to the Swing sources, you can keep developing the Swing mainline and re-run the swap on each merged delta. Business logic crosses once.
Landing is a legitimate place to stop: off the desktop, on Vaadin, with source you own, refined toward idiomatic Vaadin one view at a time as far as the business case justifies. The catch is that the code stays Swing-shaped, on an emulation layer maintained on a best-effort basis. If you later want idiomatic Vaadin code, that rewrite is deferred rather than avoided.
What a real migration changed
testapps/inventory is Ganesh Tiwari's java-inventory-management-system-swing-hibernate, written by somebody who had never heard of this project: Hibernate with an embedded H2 database, sixty-five source files, a login window, 42 JOptionPane calls and two third-party Swing libraries. Before the port we made the few changes its PROVENANCE.md lists, among them a database seed and dropping one SwingX call.
An agent migrated it by following the kit's guide. At commit b51bd3c we classified every line of the 64 files both versions share. A line is mechanical if the swap tool rewrote it (imports, blanks between them, fully qualified type references); anything else the agent changed is a hand edit.
| inventory, 64 shared files (10,082 lines) | |
|---|---|
| Lines byte-identical after migration | 9,811 (97.3%) |
| Lines rewritten mechanically | 192 (1.9%) |
| Lines changed or removed by hand | 79 (0.8%) |
| Lines added by hand | 117 |
That is 196 hand-edited lines, 156 of them in three files. Main got the one structural refactor every migration does, splitting the process entry point from the per-UI entry point. AppFrame kept its login state in a static field, and Seed needed its constants declared safe to share between sessions. The port also deleted the 67-line AppStarter and added five small files.
Line counts don't prove behavior, though. In the first port, three Saves that write to components from a SwingWorker silently did nothing. The fix went into the emulators; the app code stayed as it was. In our run on 30 September Item Entry saved directly, but Transfer and Return rejected a quantity typed into the table until we opened another cell: where Swing commits the app's custom cell editor on Enter, Tab or a button click, the emulated table did not. That gap was still open at b51bd3c, and our browser-only click-through didn't cover every screen.
Modal dialogs still block
In Swing, JOptionPane.showConfirmDialog does not return until the user answers, and code written against that reads straight down the page:
int result = JOptionPane.showConfirmDialog(this, "Delete this record?");
if (result == JOptionPane.YES_OPTION) {
deleteRecord();
}
A web application cannot normally block like that. Vaadin handles each request on a server thread that holds the user's session lock, and the browser sees nothing until that thread lets go. A call that simply waited would never send the response, and the session would hang. This is why most web toolkits make you rewrite dialog code into callbacks.
The emulators handle it: a virtual thread parks the waiting code and hands the request thread back, the dialog renders, and the call returns the user's choice. You need Vaadin Push and JDK 24 or later. Before JEP 491, a virtual thread parked inside a synchronized block pinned the request thread holding the session lock, and the dialog never rendered.
One case can still freeze a session on any JDK: a dialog opened inside a synchronized block while another listener waits for that monitor. hazard-scan lists every synchronized region in your sources.
All 42 of the inventory app's dialog calls came through unchanged, and the confirmations we clicked blocked and returned the answer.
Running a migration
The kit is one archive: a guide of seven phases with tickable steps, reference documents, three example apps and three tools.
hazard-scanreports which of 22 known migration hazards it finds, fromSystem.exitcalls and staticJFramesingletons to timezone drift.static-sweepbuilds the static-field worklist from your compiled classes. A field that was safe in one desktop JVM is shared by every session on a server.import-swapdoes the rewrite, driven by a table of ported types read off the classpath.
None of them needs the network: the scan and the rewrite run on an air-gapped machine. Building the migrated app still needs Maven Central or a mirror, and an agent sends the code it reads to its model.
The guardrails module is an ArchUnit rule set for your migrated app's tests. It fails the build on unvetted static state, on a component held in a static field, and on System.exit. It resolves the type hierarchy, so it catches static MainFrame INSTANCE where a grep for static JFrame would not.
SwingBridge Skills
The guides were written to be followed by an agent. Three Claude Code skills ship in the kit: /migrate-testapp for a bundled example, /migrate-your-app for an app you copy into the kit, and /migrate-swing-app <folder> for one in its own git checkout, migrated in place as a single diff to review. A skill is done when the app compiles, starts and survives a click-through. The first inventory port met that bar with three broken Saves, so checking behavior stays with you.
For other agents, agent-prompt.md holds the same instructions as plain Markdown, and the guides work by hand too.
SwingBridge MCP
SwingBridge MCP is a -javaagent you attach to your Swing app before you migrate it. It exposes the desktop app's accessibility tree and interactions over MCP, so an agent can drive it and record how it behaves, then check the browser app against that. Version 1.0 is on the swing-mcp releases page. It serves one session at a time and has no authentication. Keep it on your own machine.
What will not migrate
Three capabilities are permanently out of scope. JApplet is gone, as is the JDK class itself as of Java 26. Look-and-feel dispatch is out: UIManager.setLookAndFeel and pluggable *UI classes go away, and styling becomes a Vaadin and CSS concern. User-authored Graphics painting is out: paintComponent overrides, custom-painted widgets and JTable.print(). The one exception is a hand-written Printable.print(Graphics), which the optional printing module renders to a PDF download.
There is no binary drop-in. Every Swing-touching dependency has to be recompiled against vaadinx.*, from source or through an add-on. Two add-ons ship today, for JGoodies Forms 1.2.1 and JCalendar 1.4, as free starter prototypes.
A Swing app whose own code is built on Spring isn't supported yet, because a singleton bean holding one user's state would leak it to the next. Hosting a Spring-free app on Spring Boot is fine.
If most of your app's value sits in custom painting, look-and-feel work or libraries you have no source for, Streamer covers them.
Try it
The repository is github.com/vaadin/swingbridge-emulators. The libraries are on Maven Central as 1.0.0-rc1; the kit is built from the main branch as 0.1-SNAPSHOT, newer than rc1 and what our b51bd3c port ran on. Maintenance is best-effort, but issues get read.
The emulator library is GPLv2 with the Classpath Exception, like OpenJDK; add-ons and example apps have their own licenses. Linking your app against the library creates no obligation to publish your source.
A migrated app also needs the emulators' servlet or Spring Boot module, requests on platform threads, and --add-opens java.base/java.lang=ALL-UNNAMED. It builds and runs on JDK 24+, though its bytecode can target Java 21.
Build once (-DskipTests skips the repository's own tests):
git clone https://github.com/vaadin/swingbridge-emulators
cd swingbridge-emulators && ./mvnw -C clean install -DskipTests
That produces the kit, zip-distro/target/swingbridge-emulators-0.1-SNAPSHOT-dist.zip. The cheapest test of fit is a read-only scan of your own app from the unzipped kit:
tools/bin/hazard-scan <your app>/src/main/java --report hazards.md
To see the emulated surface in a browser, run ./mvnw -C -pl sampler exec:exec in the repository and open http://localhost:8080. To migrate a bundled example with an agent, start Claude Code in the unzipped kit and type:
/migrate-testapp crud
The skill works on a copy in work/; testapps/crud/1-emulators/ is the finished migration to diff against.
We would like to hear from anyone who runs this against a real application, particularly about which third-party Swing libraries block you and which hazards the scan missed. Both go in the repository's issue tracker.