The 0.4-Second Gap — Building Auto Focus
Implementing automatic task focusing on start and unfocusing on completion, and the timing, animation, and state synchronization challenges along the way.
The 0.4-Second Gap
Structure: The Focus Slot
Fecit’s Tasks tab has a focus slot sitting above the FlatList. When achiever.focusedTaskRecordId exists, the slot appears and the corresponding task is pulled out of the list into it.
┌─────────────────────┐
│ [Focus Slot] │ ← Visible when focusedTaskRecordId exists
├─────────────────────┤
│ FlatList │ ← Focused task filtered out
│ - Task A │
│ - Task B │
│ ... │
└─────────────────────┘
Auto focus hooks into this flow. Set focusedTaskRecordId on state change, the slot appears. Clear it, the slot disappears.
Optimistic Update
The focus API requires a network round trip. Waiting for the response before showing the slot introduces visible delay. So we use optimistic updates.
// Immediately reflect in local state
setAchiever((prev) => ({
...prev,
focusedTaskRecordId: task.id,
}));
// API call is fire-and-forget
setTaskRecordIsFocused(task.id, true).catch(() => {
// Rollback on failure
setAchiever((prev) =>
prev?.focusedTaskRecordId === task.id
? { ...prev, focusedTaskRecordId: undefined }
: prev
);
});
The slot appears the instant you tap. If the API fails, it silently reverts. If it succeeds, nothing happens — the state is already correct.
Entry Animations: Four Into One
Beyond the focus slot appearing, there are three other situations where a task enters the list: new creation, renew (date update), and returning from focus.
Originally, all four used different animations. New creation had a spring scale, renew had a border flash, focus/unfocus used height expand. We unified them.
The CellRendererComponent applies height expand as the common entry animation:
const shouldExpand = taskId != null && (
taskId === justUnfocusedIdRef.current ||
taskId === justCreatedIdRef.current ||
taskId === justModifiedIdRef.current
);
const entering = shouldExpand
? (values) => ({
initialValues: { opacity: 0, height: 0 },
animations: {
opacity: withTiming(1, { duration: 200 }),
height: withTiming(values.targetHeight, { duration: 240 }),
},
})
: undefined;
The base entry is identical; decorations differ. New creation gets a glow overlay, renew gets a border flash. Unified entry gives the list a consistent rhythm.
The Focus Slot Is Different
But the focus slot didn’t work with height expand. It’s a separate Animated.View above the FlatList with overflow: "hidden", so the height animation felt like a curtain being drawn — revealing pre-rendered content behind a growing mask. In the FlatList, items below push down as a cell grows, creating an organic “making space” feel. The slot, being isolated, gave a different impression with the same animation.
We switched to scale up:
entering={() => ({
initialValues: {
opacity: 0,
transform: [{ scaleY: 0.95 }, { scaleX: 0.98 }],
},
animations: {
opacity: withTiming(1, { duration: 200 }),
transform: [
{ scaleY: withTiming(1, { duration: 250, easing: Easing.out(Easing.cubic) }) },
{ scaleX: withTiming(1, { duration: 250, easing: Easing.out(Easing.cubic) }) },
],
},
})}
Slightly compressed, then expanding to full size. 250ms, Easing.out(cubic). Same concept of “appearing,” but suited to the context.
Background Tint Pulse
To make the focus moment more noticeable, we added a background tint pulse. An Animated.View layered on the FocusedTaskWrapper, PRIMARY400 at 8% opacity, fading in over 150ms and out over 600ms.
if (isJustFocused) {
tintOpacity.value = withSequence(
withTiming(1, { duration: 150, easing: Easing.out(Easing.quad) }),
withTiming(0, { duration: 600, easing: Easing.inOut(Easing.ease) }),
);
}
The 8% value matches the NewListItemIndicator used for newly created tasks. Across the app, the visual intensity of “something just happened” stays consistent.
Timing the Release
The most-tuned aspect of auto unfocus was timing.
0ms: Completing immediately collapses the slot. Functionally correct, but the completion is gone before you feel it.
1500ms: First attempt. Too long. You want to move on, but the slot lingers.
400ms: The completed task shows its new state for one beat, then the slot starts collapsing. This felt right.
if (autoUnfocus) {
setTimeout(() => {
setAchiever((prev) => {
if (prev?.focusedTaskRecordId !== task.id) return prev;
return { ...prev, focusedTaskRecordId: undefined };
});
}, 400);
}
When the slot collapses, the task needs to re-enter the FlatList. Timing matters here too.
Filter Delay: 480ms
The focused task is filtered out of the FlatList. Removing the filter immediately on unfocus means the same task appears in both the collapsing slot and the list. One becomes two.
delayedFocusFilterId delays the filter release by 480ms. The task enters the list only after the slot has fully collapsed, triggering the CellRenderer’s height expand animation.
[0ms] Complete button pressed
[400ms] focusedTaskRecordId = undefined → slot starts collapsing
[480ms] Filter released → task enters list with height expand
The 40–80ms overlap between slot collapse and list entry makes the transition seamless.
CellRenderer and Refs
The CellRendererComponent is created with useMemo(() => ..., []) — no dependencies. If FlatList detects a new cell component, it remounts everything.
This means atom values like justCreatedId or justModifiedId aren’t accessible inside the renderer. The dependency array is empty. We mirror them to refs:
const justCreatedIdRef = useRef<string | undefined>(undefined);
const justModifiedIdRef = useRef<string | undefined>(undefined);
// Sync during render
justCreatedIdRef.current = justCreatedId;
justModifiedIdRef.current = justModifiedId;
Inside CellRenderer, we read .current. Refs always point to the latest value regardless of dependency arrays.
Context-Aware Suggestion Modal
Users who disable auto focus see a “Focus on this task?” modal instead. But it shouldn’t appear everywhere.
Starting a task from the detail page (BrowseTaskRecordPage) and then seeing a modal floating over the list when you navigate back breaks context.
A suppressFocusSuggestion prop disables the suggestion in the detail page. The modal only appears when starting from the list. Auto focus works everywhere; manual suggestion only where it makes sense.
The Numbers
Timing values involved in one feature:
- 200ms: opacity fade in
- 240ms: height expand (list cells)
- 250ms: scale up (focus slot)
- 150ms + 600ms: tint pulse (in + out)
- 400ms: unfocus delay after completion
- 480ms: filter release delay
- 1500ms: star dance duration
Each is a small number. Together, they make “start and it rises, finish and it settles” feel natural.