You can build a desktop world clock in Java Swing by converting one Instant into several time zones and drawing each clock face with Java 2D. This example displays an analog dial, a digital time, the local date, and the UTC offset for each city. The widget can stay above other windows while you work.

Source code
Save the source as WorldClock.java. The example uses Java 17 or later and runs with the standard JDK.
package org.kodejava.swing;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.prefs.Preferences;
public final class WorldClock {
private static final Color BACKGROUND = new Color(13, 21, 35);
private static final Color CARD = new Color(24, 36, 54);
private static final Color TEXT = new Color(240, 245, 250);
private static final Color MUTED = new Color(166, 184, 205);
private static final Color[] ACCENTS = {
new Color(121, 224, 196), new Color(247, 190, 118),
new Color(165, 186, 255), new Color(250, 158, 175)
};
private static final DateTimeFormatter DATE =
DateTimeFormatter.ofPattern("EEE, d MMM", Locale.ENGLISH);
private static final DateTimeFormatter[] TIME_FORMATS = {
DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT),
DateTimeFormatter.ofPattern("HH:mm:ss", Locale.ROOT),
DateTimeFormatter.ofPattern("h:mm a", Locale.ENGLISH),
DateTimeFormatter.ofPattern("h:mm:ss a", Locale.ENGLISH)
};
private static final String[] CITY_DATA = {
"Amsterdam|Europe/Amsterdam", "Auckland|Pacific/Auckland", "Bangkok|Asia/Bangkok",
"Beijing|Asia/Shanghai", "Berlin|Europe/Berlin", "Denpasar|Asia/Makassar",
"Dubai|Asia/Dubai", "Hong Kong|Asia/Hong_Kong", "Honolulu|Pacific/Honolulu",
"Istanbul|Europe/Istanbul", "Jakarta|Asia/Jakarta", "Johannesburg|Africa/Johannesburg",
"Kuala Lumpur|Asia/Kuala_Lumpur", "London|Europe/London",
"Los Angeles|America/Los_Angeles", "Manila|Asia/Manila",
"Mexico City|America/Mexico_City", "Moscow|Europe/Moscow", "New Delhi|Asia/Kolkata",
"New York|America/New_York", "Paris|Europe/Paris", "Perth|Australia/Perth",
"São Paulo|America/Sao_Paulo", "Seoul|Asia/Seoul", "Singapore|Asia/Singapore",
"Sydney|Australia/Sydney", "Tokyo|Asia/Tokyo", "Toronto|America/Toronto",
"UTC|UTC", "Vancouver|America/Vancouver"
};
private static final String DEFAULTS = "Denpasar|Jakarta|Tokyo|London|New York";
private static final List<City> CATALOG = java.util.Arrays.stream(CITY_DATA)
.map(entry -> entry.split("\\|"))
.map(parts -> new City(parts[0], ZoneId.of(parts[1])))
.toList();
private record City(String name, ZoneId zone) {
@Override
public String toString() {
return name;
}
}
private final JFrame frame = new JFrame("World Clock");
private final JPanel content = new JPanel(new BorderLayout(0, 10));
private final JPanel list = new JPanel();
private final JScrollPane scroll = new JScrollPane(list);
private final List<City> cities = new ArrayList<>();
private final List<CityCard> cards = new ArrayList<>();
private final Preferences settings;
private final Timer timer = new Timer(50, event -> refreshClocks());
private final JLabel empty = label("Use + City to add a clock.", 13, MUTED);
private TrayIcon trayIcon;
private boolean seconds;
private boolean twelveHour;
private boolean compact;
private WorldClock(boolean preview) {
settings = preview ? null : Preferences.userNodeForPackage(WorldClock.class)
.node("world-clock-swing");
boolean pinned = settings == null || settings.getBoolean("pinned", true);
seconds = settings == null || settings.getBoolean("seconds", true);
twelveHour = settings != null && settings.getBoolean("twelveHour", false);
compact = settings != null && settings.getBoolean("compact", false);
String names = settings == null ? DEFAULTS : settings.get("cities", DEFAULTS);
for (String name : names.split("\\|")) {
CATALOG.stream().filter(city -> city.name().equals(name) && !cities.contains(city))
.findFirst().ifPresent(cities::add);
}
frame.setUndecorated(true);
frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
frame.setAlwaysOnTop(pinned);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent event) {
hideWidget();
}
});
content.setBackground(BACKGROUND);
content.setBorder(new EmptyBorder(10, 12, 12, 12));
frame.setContentPane(content);
JPanel header = new JPanel(new BorderLayout(8, 0));
header.setOpaque(false);
JLabel title = label("World Clock", 20, TEXT);
title.setFont(title.getFont().deriveFont(Font.BOLD));
header.add(title, BorderLayout.CENTER);
JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
actions.setOpaque(false);
JButton add = button("+ City");
JButton options = button("Options");
JButton hide = button("−");
hide.setToolTipText("Hide widget");
hide.getAccessibleContext().setAccessibleName("Hide widget");
add.addActionListener(event -> addCity());
options.addActionListener(event -> showOptions(options));
hide.addActionListener(event -> hideWidget());
actions.add(add);
actions.add(options);
actions.add(hide);
header.add(actions, BorderLayout.EAST);
enableDrag(title);
enableDrag(header);
content.add(header, BorderLayout.NORTH);
list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
list.setBackground(BACKGROUND);
scroll.setBorder(null);
scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
scroll.getVerticalScrollBar().setUnitIncrement(24);
content.add(scroll, BorderLayout.CENTER);
frame.getRootPane().registerKeyboardAction(event -> hideWidget(),
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_IN_FOCUSED_WINDOW);
Rectangle area = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
frame.setBounds(area.x + area.width - 384, area.y + 24, 360, 200);
if (settings != null) {
frame.setLocation(settings.getInt("x", frame.getX()), settings.getInt("y", frame.getY()));
}
rebuildCards();
if (!preview) installTray();
}
private static JLabel label(String text, int size, Color color) {
JLabel label = new JLabel(text);
label.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, size));
label.setForeground(color);
return label;
}
private static JButton button(String text) {
JButton button = new JButton(text);
button.setBackground(CARD);
button.setForeground(TEXT);
button.setMargin(new Insets(4, 8, 4, 8));
return button;
}
private void enableDrag(Component target) {
MouseAdapter drag = new MouseAdapter() {
private Point offset;
@Override
public void mousePressed(MouseEvent event) {
if (!SwingUtilities.isLeftMouseButton(event)) return;
Point pointer = event.getLocationOnScreen();
offset = new Point(pointer.x - frame.getX(), pointer.y - frame.getY());
}
@Override
public void mouseDragged(MouseEvent event) {
if (offset == null) return;
Point pointer = event.getLocationOnScreen();
frame.setLocation(pointer.x - offset.x, pointer.y - offset.y);
}
@Override
public void mouseReleased(MouseEvent event) {
if (offset == null) return;
offset = null;
fitToScreen();
save();
}
};
target.addMouseListener(drag);
target.addMouseMotionListener(drag);
}
private void fitToScreen() {
GraphicsConfiguration screen = frame.getGraphicsConfiguration();
Rectangle area = new Rectangle(screen.getBounds());
Insets insets = Toolkit.getDefaultToolkit().getScreenInsets(screen);
area.x += insets.left;
area.y += insets.top;
area.width -= insets.left + insets.right;
area.height -= insets.top + insets.bottom;
Dimension preferred = content.getPreferredSize();
int width = Math.min(area.width, 360);
int height = Math.min(area.height, preferred.height);
int x = Math.max(area.x, Math.min(frame.getX(), area.x + area.width - width));
int y = Math.max(area.y, Math.min(frame.getY(), area.y + area.height - height));
frame.setBounds(x, y, width, height);
}
private void addCity() {
City[] available = CATALOG.stream().filter(city -> !cities.contains(city)).toArray(City[]::new);
if (available.length == 0) {
JOptionPane.showMessageDialog(frame, "All cities are already displayed.");
return;
}
City choice = (City) JOptionPane.showInputDialog(frame, "Choose a city or time zone",
"Add City", JOptionPane.PLAIN_MESSAGE, null, available, available[0]);
if (choice == null) return;
cities.add(choice);
rebuildCards();
save();
CityCard added = cards.get(cards.size() - 1);
SwingUtilities.invokeLater(() -> list.scrollRectToVisible(added.getBounds()));
}
private void showOptions(JButton anchor) {
JPopupMenu menu = new JPopupMenu();
toggle(menu, "Always on top", frame.isAlwaysOnTop(), frame::setAlwaysOnTop);
toggle(menu, "Show seconds", seconds, value -> seconds = value);
toggle(menu, "12-hour format", twelveHour, value -> twelveHour = value);
toggle(menu, "Compact layout", compact, value -> {
compact = value;
rebuildCards();
});
menu.addSeparator();
item(menu, "Exit", true, this::exit);
menu.show(anchor, 0, anchor.getHeight());
}
private void toggle(JPopupMenu menu, String title, boolean checked,
java.util.function.Consumer<Boolean> change) {
JCheckBoxMenuItem item = new JCheckBoxMenuItem(title, checked);
item.addActionListener(event -> {
change.accept(item.isSelected());
refreshClocks();
save();
});
menu.add(item);
}
private static void item(JPopupMenu menu, String title, boolean enabled, Runnable action) {
JMenuItem item = new JMenuItem(title);
item.setEnabled(enabled);
item.addActionListener(event -> action.run());
menu.add(item);
}
private void showCityMenu(JButton anchor, City city) {
int index = cities.indexOf(city);
JPopupMenu menu = new JPopupMenu();
item(menu, "Move up", index > 0, () -> moveCity(index, index - 1));
item(menu, "Move down", index < cities.size() - 1, () -> moveCity(index, index + 1));
item(menu, "Remove", true, () -> {
cities.remove(city);
rebuildCards();
save();
});
menu.show(anchor, 0, anchor.getHeight());
}
private void moveCity(int index, int destination) {
City city = cities.remove(index);
cities.add(destination, city);
rebuildCards();
save();
CityCard moved = cards.get(destination);
SwingUtilities.invokeLater(() -> list.scrollRectToVisible(moved.getBounds()));
}
private void rebuildCards() {
list.removeAll();
cards.clear();
if (cities.isEmpty()) {
empty.setBorder(new EmptyBorder(12, 8, 12, 8));
list.add(empty);
}
for (int index = 0; index < cities.size(); index++) {
if (index > 0) list.add(Box.createVerticalStrut(6));
City city = cities.get(index);
Color accent = ACCENTS[CATALOG.indexOf(city) % ACCENTS.length];
CityCard card = new CityCard(city, accent);
cards.add(card);
list.add(card);
}
list.revalidate();
fitToScreen();
list.repaint();
refreshClocks();
}
private void refreshClocks() {
Instant now = Instant.now();
LocalDate localDate = now.atZone(ZoneId.systemDefault()).toLocalDate();
int formatIndex = (twelveHour ? 2 : 0) + (seconds ? 1 : 0);
for (CityCard card : cards) {
ZonedDateTime cityTime = now.atZone(card.city.zone());
card.refresh(cityTime, localDate, TIME_FORMATS[formatIndex]);
}
}
private void save() {
if (settings == null) return;
settings.put("cities", String.join("|", cities.stream().map(City::name).toList()));
settings.putInt("x", frame.getX());
settings.putInt("y", frame.getY());
settings.putBoolean("pinned", frame.isAlwaysOnTop());
settings.putBoolean("seconds", seconds);
settings.putBoolean("twelveHour", twelveHour);
settings.putBoolean("compact", compact);
}
private void installTray() {
if (!SystemTray.isSupported()) return;
BufferedImage icon = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = icon.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(CARD);
g.fillOval(0, 0, 32, 32);
g.setColor(ACCENTS[0]);
g.setStroke(new BasicStroke(2.4f));
g.drawOval(3, 3, 26, 26);
g.drawLine(16, 16, 16, 7);
g.drawLine(16, 16, 23, 20);
} finally {
g.dispose();
}
PopupMenu menu = new PopupMenu();
MenuItem show = new MenuItem("Show World Clock");
show.addActionListener(event -> SwingUtilities.invokeLater(this::showWidget));
menu.add(show);
MenuItem close = new MenuItem("Exit");
close.addActionListener(event -> SwingUtilities.invokeLater(this::exit));
menu.add(close);
TrayIcon iconCandidate = new TrayIcon(icon, "World Clock", menu);
iconCandidate.setImageAutoSize(true);
iconCandidate.addActionListener(event -> SwingUtilities.invokeLater(this::showWidget));
try {
SystemTray.getSystemTray().add(iconCandidate);
trayIcon = iconCandidate;
} catch (AWTException error) {
// The taskbar remains available if a tray icon cannot be registered.
}
}
private void showWidget() {
fitToScreen();
frame.setExtendedState(JFrame.NORMAL);
frame.setVisible(true);
frame.toFront();
refreshClocks();
timer.start();
}
private void hideWidget() {
if (trayIcon != null) {
timer.stop();
frame.setVisible(false);
} else {
frame.setExtendedState(JFrame.ICONIFIED);
}
}
private void exit() {
timer.stop();
save();
if (trayIcon != null) SystemTray.getSystemTray().remove(trayIcon);
frame.dispose();
}
private final class CityCard extends JPanel {
private final City city;
private final Dial dial;
private final JLabel digital = label("", compact ? 17 : 21, TEXT);
private final JLabel date = label("", 12, MUTED);
CityCard(City city, Color accent) {
super(new BorderLayout(10, 0));
this.city = city;
setOpaque(false);
setBorder(new EmptyBorder(6, 9, 6, 8));
int height = compact ? 70 : 104;
setPreferredSize(new Dimension(320, height));
setMaximumSize(new Dimension(Integer.MAX_VALUE, height));
setAlignmentX(Component.LEFT_ALIGNMENT);
dial = new Dial(accent, height - 12);
add(dial, BorderLayout.WEST);
JPanel info = new JPanel(new GridBagLayout());
info.setOpaque(false);
JPanel text = new JPanel();
text.setOpaque(false);
text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS));
JLabel name = label(city.name(), 13, accent);
name.setFont(name.getFont().deriveFont(Font.BOLD));
digital.setFont(new Font(Font.MONOSPACED, Font.BOLD, compact ? 17 : 21));
text.add(name);
text.add(digital);
text.add(date);
GridBagConstraints layout = new GridBagConstraints();
layout.weightx = 1;
layout.fill = GridBagConstraints.HORIZONTAL;
info.add(text, layout);
add(info, BorderLayout.CENTER);
JButton actions = button("⋯");
actions.setMargin(new Insets(2, 5, 2, 5));
actions.getAccessibleContext().setAccessibleName("Options for " + city.name());
actions.addActionListener(event -> showCityMenu(actions, city));
JPanel actionPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 2));
actionPanel.setOpaque(false);
actionPanel.add(actions);
add(actionPanel, BorderLayout.EAST);
}
void refresh(ZonedDateTime time, LocalDate localDate, DateTimeFormatter format) {
long difference = ChronoUnit.DAYS.between(localDate, time.toLocalDate());
String day = difference == 0 ? "" : String.format(Locale.ROOT, " · %+dd", difference);
String offset = time.getOffset().equals(ZoneOffset.UTC) ? "+00:00" : time.getOffset().getId();
digital.setText(time.format(format));
date.setText(time.format(DATE) + " · UTC" + offset + day);
dial.update(time, seconds);
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D g = (Graphics2D) graphics.create();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(CARD);
g.fillRoundRect(0, 0, getWidth(), getHeight(), 20, 20);
} finally {
g.dispose();
}
}
}
private static final class Dial extends JComponent {
private final Color accent;
private ZonedDateTime time;
private boolean showSeconds;
Dial(Color accent, int size) {
this.accent = accent;
setPreferredSize(new Dimension(size, size));
}
void update(ZonedDateTime nextTime, boolean seconds) {
boolean minuteChanged = time == null ||
!time.truncatedTo(ChronoUnit.MINUTES).equals(nextTime.truncatedTo(ChronoUnit.MINUTES));
boolean repaintNeeded = seconds || minuteChanged || showSeconds != seconds;
time = nextTime;
showSeconds = seconds;
if (repaintNeeded) repaint();
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
if (time == null) return;
Graphics2D g = (Graphics2D) graphics.create();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
double size = Math.min(getWidth(), getHeight());
g.translate(getWidth() / 2.0, getHeight() / 2.0);
g.scale(size / 100.0, size / 100.0);
g.setPaint(new GradientPaint(-35, -48, new Color(49, 70, 93),
30, 49, new Color(11, 19, 33)));
g.fill(new Ellipse2D.Double(-48, -48, 96, 96));
g.setColor(accent);
g.setStroke(new BasicStroke(1.6f));
g.draw(new Ellipse2D.Double(-46, -46, 92, 92));
g.setPaint(new RadialGradientPaint(new Point2D.Double(-13, -17), 65,
new float[]{0, 1}, new Color[]{new Color(42, 59, 79), new Color(9, 16, 29)}));
g.fill(new Ellipse2D.Double(-43, -43, 86, 86));
for (int tick = 0; tick < 60; tick++) {
boolean hour = tick % 5 == 0;
double angle = tick * Math.PI / 30;
g.setColor(hour ? accent : new Color(102, 124, 149));
g.setStroke(new BasicStroke(hour ? 1.7f : 0.7f, BasicStroke.CAP_ROUND,
BasicStroke.JOIN_ROUND));
g.draw(radialLine(angle, hour ? 35 : 38, 40));
}
g.setColor(TEXT);
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 9));
numeral(g, "12", 0, -27);
numeral(g, "3", 27, 0);
numeral(g, "6", 0, 27);
numeral(g, "9", -27, 0);
double second = showSeconds ? time.getSecond() + time.getNano() / 1_000_000_000.0 : 0;
double minute = time.getMinute() + second / 60;
double hour = time.getHour() % 12 + minute / 60;
hand(g, hour / 12, 23, 4.3f, TEXT);
hand(g, minute / 60, 33, 2.9f, TEXT);
if (showSeconds) hand(g, second / 60, 36, 1.4f, accent);
g.setColor(accent);
g.fill(new Ellipse2D.Double(-3.5, -3.5, 7, 7));
g.setColor(BACKGROUND);
g.fill(new Ellipse2D.Double(-1.3, -1.3, 2.6, 2.6));
} finally {
g.dispose();
}
}
private static void numeral(Graphics2D g, String text, int x, int y) {
FontMetrics metrics = g.getFontMetrics();
g.drawString(text, x - metrics.stringWidth(text) / 2.0f,
y + (metrics.getAscent() - metrics.getDescent()) / 2.0f);
}
private static void hand(Graphics2D g, double turn, double length, float width, Color color) {
g.setColor(color);
g.setStroke(new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.draw(radialLine(turn * 2 * Math.PI, -5, length));
}
private static Line2D radialLine(double angle, double start, double end) {
double x = Math.sin(angle);
double y = -Math.cos(angle);
return new Line2D.Double(x * start, y * start, x * end, y * end);
}
}
private static void verifyAndRender(Path output) throws IOException {
Instant winter = Instant.parse("2026-01-15T19:30:00Z");
Instant summer = Instant.parse("2026-07-15T19:30:00Z");
if (CATALOG.size() != 30 ||
winter.atZone(ZoneId.of("Asia/Makassar")).getDayOfMonth() != 16 ||
!winter.atZone(ZoneId.of("America/New_York")).getOffset().equals(ZoneOffset.ofHours(-5)) ||
!summer.atZone(ZoneId.of("America/New_York")).getOffset().equals(ZoneOffset.ofHours(-4)) ||
!summer.atZone(ZoneId.of("Europe/London")).getOffset().equals(ZoneOffset.ofHours(1))) {
throw new IllegalStateException("Time zone check failed");
}
WorldClock widget = new WorldClock(true);
try {
widget.frame.validate();
widget.frame.addNotify();
widget.frame.validate();
if (widget.cards.size() != 5 || !widget.frame.isAlwaysOnTop() ||
widget.cards.get(0).dial.getWidth() <= 0) {
throw new IllegalStateException("Initial widget layout check failed");
}
widget.moveCity(0, 1);
if (!widget.cards.get(1).city.name().equals("Denpasar")) {
throw new IllegalStateException("Reorder check failed");
}
widget.moveCity(1, 0);
widget.frame.validate();
for (CityCard card : widget.cards) {
card.refresh(winter.atZone(card.city.zone()), LocalDate.of(2026, 1, 16), TIME_FORMATS[1]);
}
if (!widget.cards.get(0).digital.getText().equals("03:30:00") ||
!widget.cards.get(3).date.getText().endsWith("-1d")) {
throw new IllegalStateException("Displayed time or date check failed");
}
widget.frame.validate();
if (widget.cards.get(0).digital.getWidth() <= 0 || widget.cards.get(0).date.getWidth() <= 0) {
throw new IllegalStateException("Clock label layout check failed");
}
BufferedImage image = new BufferedImage(widget.content.getWidth(), widget.content.getHeight(),
BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
try {
widget.content.printAll(g);
} finally {
g.dispose();
}
javax.imageio.ImageIO.write(image, "png", output.toFile());
widget.compact = true;
widget.rebuildCards();
widget.cities.clear();
widget.rebuildCards();
if (!widget.cards.isEmpty() || widget.list.getComponentCount() != 1) {
throw new IllegalStateException("Empty state check failed");
}
} finally {
widget.exit();
}
}
public static void main(String[] args) throws Exception {
if (args.length == 2 && args[0].equals("--verify")) {
SwingUtilities.invokeAndWait(() -> {
try {
verifyAndRender(Path.of(args[1]));
} catch (IOException error) {
throw new java.io.UncheckedIOException(error);
}
});
return;
}
SwingUtilities.invokeLater(() -> new WorldClock(false).showWidget());
}
}
Use one instant for every city
A world clock displays the same moment in different places. The refresh method takes one snapshot of the system clock. Each card then converts that snapshot into a ZonedDateTime using its city’s IANA time-zone ID.
Instant now = Instant.now();
ZonedDateTime denpasar = now.atZone(ZoneId.of("Asia/Makassar"));
ZonedDateTime newYork = now.atZone(ZoneId.of("America/New_York"));
Using a region-based ZoneId lets Java apply the correct offset for that instant. New York, for example, can show either UTC−05:00 or UTC−04:00 depending on the date. The offset in each card comes from ZonedDateTime.getOffset().
To show whether a city is on a different calendar day, compare its LocalDate with the date in ZoneId.systemDefault(). A card shows -1d when its date is one day behind the computer’s local date.
Draw the clock face with Java 2D
The Dial component overrides paintComponent(). It moves the graphics origin to the center of the component and scales a 100-by-100 drawing area to fit. The same drawing code therefore works for both the regular and compact layouts.
The dial uses a gradient bezel, a shaded face, and sixty tick marks. Every fifth tick is longer and uses the city’s accent color. For each tick or hand, the following helper projects two points onto the same radial line.
private static Line2D radialLine(double angle, double start, double end) {
double x = Math.sin(angle);
double y = -Math.cos(angle);
return new Line2D.Double(x * start, y * start, x * end, y * end);
}
At zero radians, sin(0) is zero and -cos(0) is negative, so the line points toward twelve o’clock. A quarter turn points right toward three o’clock. A negative starting radius extends the hand a little behind the center.
When seconds are enabled, the fractional part of the second comes from getNano(). The minute hand uses the fraction of the current minute, and the hour hand uses the fraction of the current hour.
double second = showSeconds ? time.getSecond() + time.getNano() / 1_000_000_000.0 : 0;
double minute = time.getMinute() + second / 60;
double hour = time.getHour() % 12 + minute / 60;
hand(g, hour / 12, 23, 4.3f, TEXT);
hand(g, minute / 60, 33, 2.9f, TEXT);
if (showSeconds) hand(g, second / 60, 36, 1.4f, accent);
The example uses a Swing Timer with a 50 ms interval to repaint the moving second hand. Each tick reads the current time. If the event dispatch thread is briefly busy, the next refresh uses the current instant and the clock catches up automatically.
Keep the widget visible
The widget uses an undecorated JFrame and calls setAlwaysOnTop(true) by default. A mouse listener on the title lets you drag the window. The Options menu lets you switch off always-on-top, hide seconds, choose a 12-hour format, or switch to a compact layout.
+ City opens the city picker. Each card’s menu can move the city up or down or remove it. Java’s Preferences API stores the city order, window position, and display settings. When the system tray is available, the hide button moves the widget there. Otherwise, the widget minimizes to the taskbar.
Run the example
To compile and run from source, use a JDK.
javac --release 17 -encoding UTF-8 WorldClock.java
java WorldClock