You can create a spirograph-style animation in Java Swing by calculating points on a hypotrochoid curve and connecting them with short line segments. Drawing progressively more segments makes a bright point appear to trace the pattern.
In this SwingLoops example, a rainbow flower takes shape over time, pauses, and fades away. The application uses only Java Swing and Java 2D, without external libraries or image assets.
Source code
Save the following code as SpirographBloom.java:
import java.awt.*;
import java.awt.geom.*;
import javax.swing.*;
public class SpirographBloom extends JPanel {
private static final double CYCLE = 12.0;
private static final int SAMPLES = 2400;
private static final double R = 150;
private static final double r = 40;
private static final double d = 95;
private static final double END = 8 * Math.PI;
private final long start = System.nanoTime();
private final Timer timer = new Timer(16, _ -> repaint());
public SpirographBloom() {
setPreferredSize(new Dimension(540, 960));
setBackground(new Color(6, 9, 22));
}
@Override
public void addNotify() {
super.addNotify();
timer.start();
}
@Override
public void removeNotify() {
timer.stop();
super.removeNotify();
}
private static Point2D point(double t) {
double ratio = (R - r) / r;
double x = (R - r) * Math.cos(t)
+ d * Math.cos(ratio * t);
double y = (R - r) * Math.sin(t)
- d * Math.sin(ratio * t);
return new Point2D.Double(x, y);
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D g = (Graphics2D) graphics.create();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
double scale = Math.min(
getWidth() / 540.0, getHeight() / 960.0);
g.translate(getWidth() / 2.0, getHeight() / 2.0);
g.scale(scale, scale);
double seconds = (System.nanoTime() - start) / 1e9;
double phase = (seconds % CYCLE) / CYCLE;
double progress = Math.min(phase / 0.8, 1.0);
float fade = phase < 0.9
? 1f
: (float) Math.max(0, (1 - phase) / 0.1);
int visible = (int) (progress * SAMPLES);
for (int i = 1; i <= visible; i++) {
double t0 = END * (i - 1) / SAMPLES;
double t1 = END * i / SAMPLES;
Point2D a = point(t0);
Point2D b = point(t1);
Shape segment = new Line2D.Double(a, b);
Color color = Color.getHSBColor(
i / (float) SAMPLES, 0.75f, 1f);
g.setColor(color);
g.setComposite(
AlphaComposite.SrcOver.derive(fade * 0.12f));
g.setStroke(new BasicStroke(9f,
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.draw(segment);
g.setComposite(
AlphaComposite.SrcOver.derive(fade));
g.setStroke(new BasicStroke(2f,
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.draw(segment);
}
Point2D tip = point(END * progress);
g.setComposite(AlphaComposite.SrcOver.derive(fade));
g.setColor(new Color(245, 250, 255));
g.fill(new Ellipse2D.Double(
tip.getX() - 5, tip.getY() - 5, 10, 10));
} finally {
g.dispose();
}
}
static void main() {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("SwingLoops - Spirograph Bloom");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setContentPane(new SpirographBloom());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
Run the example
You need a Java Development Kit version 25 and a desktop environment that supports Swing windows. Open a terminal in the directory containing the source file:
javac SpirographBloom.java
java SpirographBloom
Compilation was verified with JDK 25. Visual playback has not yet been verified.
The window has a preferred drawing area of 540 × 960 pixels. If it is too tall for your screen, change new Dimension(540, 960) to new Dimension(405, 720). Close the window to stop the application.
What is a hypotrochoid?
Imagine a small circle rolling without slipping inside a larger circle. Attach a drawing point to the small circle. As the circle rolls, the point traces a curve called a hypotrochoid.
The point can lie inside, on, or beyond the edge of the rolling circle. In this example, it lies beyond the edge, creating loops that resemble flower petals.
The curve is described by two equations:
k = (R - r) / r
x(t) = (R - r) × cos(t) + d × cos(k × t)
y(t) = (R - r) × sin(t) - d × sin(k × t)
Here, R is the fixed outer circle’s radius, r is the rolling circle’s radius, and d is the distance from the rolling circle’s center to the drawing point. The parameter t is an angle measured in radians; it is not elapsed time in seconds.
The first part of each equation locates the rolling circle’s center. That center travels around a circle of radius R - r.
The second part adds the drawing point’s offset from the rolling center. The minus sign in the vertical term accounts for the opposite rotation associated with rolling inside the outer circle.
The code implements these equations in point():
double ratio = (R - r) / r;
double x = (R - r) * Math.cos(t)
+ d * Math.cos(ratio * t);
double y = (R - r) * Math.sin(t)
- d * Math.sin(ratio * t);
Swing’s vertical axis points downward, so the result is reflected vertically relative to the usual mathematical coordinate system. Neither construction circle is drawn; only the resulting curve is visible.
Why does the curve close at 8π?
For the chosen radii:
R = 150
r = 40
k = (150 - 40) / 40 = 11/4
Both angular components must return to their starting orientations for the complete tracing state to repeat.
At t = 8π:
First angle: t = 8π → 4 complete revolutions
Second angle: (11/4)t = 22π → 11 complete revolutions
This is why the example uses:
private static final double END = 8 * Math.PI;
For positive integer radii with R > r, a valid closure interval is:
END = 2π × r / gcd(R, r)
The greatest common divisor of 150 and 40 is 10, giving 2π × 40 / 10 = 8π. If you change the radii, revisit this interval rather than keeping END unchanged.
Revealing the curve over time
The animation converts elapsed seconds into a repeating phase between zero and one:
double phase = (seconds % CYCLE) / CYCLE;
The modulo operator resets the phase after every 12 seconds. Drawing occupies the first 80% of the cycle:
double progress = Math.min(phase / 0.8, 1.0);
int visible = (int) (progress * SAMPLES);
At six seconds, phase is 0.5 and progress is 0.625. The panel draws 1,500 of its 2,400 segments. At 9.6 seconds, the entire curve is visible.
Each segment joins two neighboring samples:
double t0 = END * (i - 1) / SAMPLES;
double t1 = END * i / SAMPLES;
More samples make the polyline approximate the mathematical curve more closely, but also increase the painting work. The bright drawing point is evaluated directly at END * progress, so it moves continuously between the sampled segment endpoints.
Creating the rainbow glow
The hue is based on the segment’s position along the complete curve:
Color color = Color.getHSBColor(i / (float) SAMPLES, 0.75f, 1f);
This assigns a stable color to each part of the drawing. Saturation and brightness remain fixed while the hue travels around the color wheel.
Each segment is drawn twice: first with a wide, low-opacity stroke, then with a thin, brighter stroke. The overlapping translucent strokes approximate a glow without a blur filter.
Rounded stroke caps help adjacent line segments join visually.
Holding and fading the pattern
The completed pattern remains fully visible from 9.6 to 10.8 seconds. During the last 1.2 seconds, opacity decreases linearly:
float fade = phase < 0.9
? 1f
: (float) Math.max(0, (1 - phase) / 0.1);
For example, at phase 0.95, opacity is 0.5. The same fade value is applied to the curve and drawing point.
The background is cleared on every repaint by super.paintComponent(graphics). The application redraws the visible curve from its mathematical definition rather than accumulating pixels between frames.
The sequence repeats, but the transition is not a perfectly seamless fade-in: the drawing point reappears at full brightness when the next cycle starts.
Resizing and Swing painting
The code moves the coordinate origin to the panel’s center and applies a uniform scale:
double scale = Math.min(getWidth() / 540.0, getHeight() / 960.0);
g.translate(getWidth() / 2.0, getHeight() / 2.0);
g.scale(scale, scale);
Using the smaller scale factor preserves the curve’s proportions when the window changes shape. With these parameters, the point stays within a distance of (R - r) + d = 205 logical pixels from the center, leaving room inside the default canvas.
A Swing timer requests repaints approximately every 16 milliseconds. This targets roughly 60 updates per second, but does not guarantee that frame rate. Positions depend on elapsed time rather than the number of timer events.
The interface is created on Swing’s event-dispatch thread. The timer starts when the panel becomes displayable and stops when it is removed. Painting uses a copied Graphics2D context, disposed in a finally block after drawing.
Customize the animation
| Parameter | Effect |
|---|---|
CYCLE |
Changes the total cycle duration. |
SAMPLES |
Changes the curve’s sampling detail and rendering cost. |
R and r |
Change the curve’s geometry; recalculate END afterward. |
d |
Changes the drawing point’s offset and the shape of the loops. |
9f and 2f |
Change the glow and bright stroke widths. |
0.75f in getHSBColor() |
Changes color saturation. |
| Background color | Changes the contrast behind the glowing curve. |
For a first experiment, keep R, r, and END unchanged and reduce d from 95 to 40. The drawing point then lies on the rolling circle’s edge, producing a special case called a hypocycloid.
If you increase the radii or offset, check that the curve still fits inside the drawing area.
Free source code
The complete source code is included above. Save it as SpirographBloom.java to compile and run it without additional dependencies.
Explore the SwingLoops repository for the series’ source code collection.



