TaskProgress
The one surface that covers a wait. It fetches nothing and holds no data — a stage owns the request, its AbortSignal and its error handling, and mounts this while it waits. That separation is what keeps the abort rule in one place.
It is always blocking: there is no dismiss control and no way out except the stage unmounting it. That is deliberate — a surface that can be sent to the background on the search can be sent to the background on the booking — and it means the stage must unmount it on failure too, or the error ends up trapped underneath it.
It is exported from @sheriph/fk-ui/core, not from a stage, for two reasons: two stages in this package need the same wait, and an agency with its own long operation — a booking retrieve, a bulk re-price — has no way to reach a module that is private to a stage.
The surface in flow
presentation="inline" renders where it is mounted, full width of its container. Neither stage uses it — both waits cover the screen — so this is the shape for your own operation, where the page underneath is still worth looking at. It paces its own narration, one line every 3.5 seconds, and stops on the last line. Pass activeStep instead when you have a real signal to drive it from.
Searching flights…
LHR → JFK
Wed 14 Oct 2026 · 1 adult · Economy Standard
Stage 2 — the first search load
Blocking, like every other wait. The traveller has already left the form and the offers do not exist yet, so the surface is the only thing there is to look at — and a live search takes six or seven seconds, long enough that a still page reads as a broken one. Render the skeleton grid underneath it, so lifting the surface reveals the layout that was being measured rather than a flash of nothing.
Nothing dismisses it: Escape and the scrim are prevented, and the stage unmounts it, on success and on failure. An older in-flight search is aborted by a newer requestId — and re-searching never re-covers the screen, because re-covering it would hide results the traveller is still reading.
Stage 3 — the booking submit
Blocking, and here it is not a trade-off at all. A second submit creates a second PNR — a financial bug, not a duplicate row — and a disabled button stops neither a second tab nor a double-tap nor a keyboard repeat. Escape and the scrim do nothing anywhere in this component, and on this wait the reason is simply that you cannot un-book.
Two things are required of it. The summary carries the total as well as the itinerary — the surface covering the screen while a booking is minted is the worst place to stop showing what is being paid. And the last narration line names the real final action. On failure the surface closes and focus returns to the Submit button, so the server's message is reachable with every entered value still on screen.
Both demos end themselves after 16 seconds. Nothing settles behind them and neither has an exit of its own, so reload the page if one outlives its timer.
Implementation guide
Four steps, then every prop it takes. The app-level setup this assumes — the stylesheet import, the provider, keeping the API key on the server — is in the stage 1 page.
Import it from core — nothing to install
TaskProgressships in@sheriph/fk-ui/core, the same entry point as the provider, so an app that can render a booking stage already has it. It is public there rather than private to a stage because two stages mount the same wait.Block, and own the unmount
There is no prop that makes this dismissible, and that is the decision rather than an omission: Escape and every outside interaction are prevented, so the stage is what ends it — unmount on success, and unmount on failure. Skip the failure case and the server's error is trapped underneath a surface the user cannot close.
A modal with no escape is a keyboard trap, so this is only for operations that end. On the search wait, the honest way to give someone an exit is a control that aborts the request — not one that hides the surface while the work carries on.
Pass the summary
Both stage call sites must. It is what makes the wait honest — the user is committing to a specific itinerary, and on the booking wait a specific amount, so the surface covering the screen is the last place to lose sight of either. It is typed
ReactNodebecause the stage formats it; this component imports no flight types, which is what keeps it usable for a non-flight operation.Mount it as a state, not as the request
The component never knows a request exists. Mount it when your state says "waiting", unmount it when the request settles — including on failure, because an error the user cannot reach is the worst outcome of a blocking surface. Focus returns to whatever was focused before the surface opened, without you managing a ref.
Give it narration whose last line names the real final action("Confirming your booking…"). Generic filler promises nothing and hides that the operation may still be in its first phase.
TaskProgress — props
- title
- string
Required. Becomes the accessible name of the surface, and labels the progress bar.
- presentation
- 'inline' | 'overlay'=
'inline'In flow, or covering the page on a scrim. No separate components — one prop.
- steps
- readonly string[]
Narration, one line at a time, in order. Omit for a bare bar. The last entry must name the real final action.
- activeStep
- number
Which step is current. Passing it disables the internal pace entirely — use it when you have a real signal.
- stepIntervalMs
- number=
3500Gap between self-paced steps. Ignored when activeStep is given.
- summary
- ReactNode
Context that must stay visible while waiting. Optional in the type, required at both stage call sites — see below.
- className
- string
Merged onto the card.
The two axes that vary
- presentation
- inline · overlay
inline renders in flow, full width of its container. overlay is a Radix dialog on a scrim: focus trap, scroll lock, aria-modal and portal layering all come from the primitive, and Escape and every outside interaction are prevented.
- narration
- steps absent · self-paced · activeStep
Absent is a bare bar with no text. Self-paced advances on the interval and stops on the last entry — it never loops, because a cycle that restarts reads as a failure. activeStep replaces that pace completely.
Accessibility, and what is deliberately absent
- The overlay is a Radix dialog:
role="dialog",aria-modal, the title as its accessible name, a focus trap, scroll lock and focus restored on unmount. - The bar carries no
aria-valuenow. Neither endpoint reports progress, so the bar is always indeterminate andaria-valuetextcarries the real status: the active step. An invented percentage that stalls is worse than none. - The narration is an
aria-live="polite"region. The title is outside it, so a screen reader hears the status change rather than the heading repeated on every step. aria-busy="true"belongs on the region the stage is updating — the results list, the checkout form — not on this surface. That is the stage's job, and the snippets below show where.- Nothing dismisses the surface — no Escape, no outside click, no control. That is a keyboard trap by design, so it must only ever cover an operation that ends: a request that hangs leaves the user with no way forward. Prefer a control that aborts the request over one that hides the surface.
- Under
prefers-reduced-motion: reducethe sweep becomes a static, complete bar. The narration still changes — the text was always the real status, and the movement never was.
Code samples
The two real call sites, and the one case neither stage covers. Copy takes the block as it is, comments included.
'use client';
import type { ReactNode } from 'react';
import { TaskProgress } from '@sheriph/fk-ui/core';
const SEARCH_STEPS = [
'Connecting to global airline reservation networks…',
'Scanning real-time seat inventory for 400+ carriers…',
'Evaluating fare rules, baggage policies, and stops…',
'Finalising your flight results…',
] as const;
export function FirstSearchLoad({ itinerary, waiting }: { itinerary: ReactNode; waiting: boolean }) {
return (
// aria-busy goes on the region YOU are updating — the list, not the surface.
<div aria-busy={waiting}>
<ResultsSkeleton />
{/* No dismiss: the surface blocks, and the stage is what ends it. */}
{waiting && (
<TaskProgress
title="Searching flights…"
presentation="overlay"
steps={SEARCH_STEPS}
// The stage formats this. TaskProgress imports no flight types.
summary={itinerary}
/>
)}
</div>
);
}'use client';
import { useState, type ReactNode } from 'react';
import { TaskProgress } from '@sheriph/fk-ui/core';
const BOOKING_STEPS = [
'Validating passenger credentials and APIS data…',
'Securing confirmed seat allocation with airline…',
'Transacting payment authorisation via payment gateway…',
'Confirming your booking…',
] as const;
export function SubmitBooking({ itinerary }: { itinerary: ReactNode }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setSubmitting(true);
setError(null);
try {
await completeBooking();
} catch (cause) {
// Closing the surface is what makes the error reachable. Focus returns to
// the Submit button by itself — Radix restores it when this unmounts.
setSubmitting(false);
setError(cause instanceof Error ? cause.message : 'Something went wrong');
}
}
return (
<>
<button type="button" onClick={submit} disabled={submitting}>
Pay the total
</button>
{/* No dismiss exists: the stage is what ends the surface. */}
{submitting && (
<TaskProgress
title="Confirming your booking…"
presentation="overlay"
steps={BOOKING_STEPS}
summary={itinerary}
/>
)}
</>
);
}'use client';
import { TaskProgress } from '@sheriph/fk-ui/core';
/**
* Your own long operation. `presentation` defaults to 'inline', so this renders
* in flow — full width of its container. That is the shape neither stage uses,
* because both of their waits cover the screen.
*/
export function BulkReprice({ activeStep }: { activeStep: number }) {
return (
<TaskProgress
title="Repricing your fare rules…"
steps={[
'Reading your airline rules…',
'Repricing the offering…',
'Saving the new totals…',
]}
// A real signal from your own work. Passing it replaces the internal pace
// entirely, so the narration cannot run ahead of the operation.
activeStep={activeStep}
/>
);
}