Tiimo's focus screen is one big ring: a pale band, a coloured disc in the middle with the task's emoji, and a thick arc that grows clockwise as the timer runs, with a small arrow riding on its tip. It looks like a progress indicator, but it is really a few lines of SVG. Here is how the version in my open-source Tiimo clone works.
Live demo: https://migrant620.github.io/tiimo/ (open a task and start the timer)
Source (MIT): https://github.com/migrant620/tiimo-clone
Three layers, one <Svg>
<Svg width={CANVAS} height={CANVAS} viewBox={`0 0 ${CANVAS} ${CANVAS}`}>
{/* 1. the pale band */}
<Circle cx={c} cy={c} r={(outer + disc) / 2} fill="none"
stroke={colors.aura} strokeWidth={outer - disc} />
{/* 2. the progress arc */}
<Path d={arcPath(angle)} stroke={colors.primary}
strokeWidth={arcWidth} strokeLinecap="round" fill="none" />
{/* 3. the disc on top */}
<Circle cx={c} cy={c} r={disc} fill={discColor} />
</Svg>
The band is not a filled donut. It is a single circle whose stroke is as wide as the band, drawn on the circle halfway between the outer edge and the disc. That gives you a perfect annulus without any path maths.
The disc is drawn last, so the arc can be wider than the visible band and still look tidy: the disc hides the inner half.
The arc path
SVG arcs are defined by a start point, an end point and two flags. Starting at 12 o'clock and going clockwise:
function arcPath(angle: number) {
const r = badgeRadius;
const rad = (Math.min(angle, 359.9) * Math.PI) / 180;
const x = c + r * Math.sin(rad);
const y = c - r * Math.cos(rad);
return `M ${c} ${c - r} A ${r} ${r} 0 ${angle > 180 ? 1 : 0} 1 ${x} ${y}`;
}
Three details matter:
-
sinfor x and-cosfor y puts 0° at the top instead of at 3 o'clock, so you don't need a rotate transform. - The large-arc flag (
angle > 180 ? 1 : 0) tells the renderer which of the two possible arcs to take. Forget it and the arc jumps to the short way round after the halfway mark. - The angle is clamped to 359.9. At exactly 360° the start and end points are identical, and an SVG arc between two identical points draws nothing, so your full ring would vanish in the last frame.
Zero progress: draw a dot, not an empty arc
When the timer has just started, the arc has zero length. With strokeLinecap="round" some renderers still draw a dot and some draw nothing. To keep it consistent, draw the dot yourself:
{angle > 0.05
? <Path d={arcPath(angle)} ... />
: <Circle cx={head.x} cy={head.y} r={arcWidth / 2} fill={colors.primary} />}
The arrow on the tip
The arrow is a normal icon view positioned at the arc's head and rotated to follow the direction of travel:
const head = {
x: SIZE / 2 + badgeRadius * Math.sin(rad),
y: SIZE / 2 - badgeRadius * Math.cos(rad),
};
const glyphPos = {
left: head.x - arrowSize / 2,
top: head.y - arrowSize / 2,
transform: [{ rotate: `${angle - 90}deg` }],
};
Keeping it outside the SVG means it uses the same icon font as the rest of the app and stays crisp at any size.
The bug that clipped my ring flat
For a while the outer edge of the ring looked slightly flattened at the top, bottom and sides. The cause: the canvas was exactly as wide as the ring's outer diameter. A stroke is centred on its path, and anti-aliasing spills a fraction of a pixel past the edge, so the outermost pixels fell outside the <Svg> and were cut off.
The fix is a couple of pixels of padding around the canvas, then shifting it back so the layout doesn't move:
const PAD = 2;
const CANVAS = SIZE + PAD * 2;
// ...
canvas: { position: 'absolute', left: -PAD, top: -PAD },
If a round shape in react-native-svg ever looks "almost round", check whether its stroke touches the canvas edge.
Try it
The whole component is about 90 lines: src/components/TimerRing.tsx. The demo runs in any browser, no install needed: https://migrant620.github.io/tiimo/
This is one of six app UIs I've rebuilt with React Native + Expo (Airbnb, Threads, Luma, Suno, Tiimo, Structured). All of them are MIT-licensed and live at https://migrant620.github.io/. Next in this series: why my Expo web demos showed a blank page on slow networks, and what fixed it.

Top comments (1)
The 359.9 clamp is such a good catch, that last-frame vanish is the kind of bug you only spot once someone films it. Does the arc update from JS every tick, or did you drive it with Reanimated animatedProps so it stays smooth while the JS thread is busy?