Impersonation Banner
The bar support staff sees while acting as a customer — whose account this is, who is inside it, what the grant may write, a live countdown to the automatic cut-off, and an exit that is never blocked.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/impersonation-banner.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ImpersonationBanner" component using
lucide-react, the shadcn Button / Badge / Tooltip primitives and the local cn()
helper (clsx + tailwind-merge). It is the bar a support agent sees while acting
as a customer: whose account this is, who is inside it, what this grant is
allowed to do, how long it has left, and the way out.
Contract
- forwardRef<HTMLDivElement>; props extend
Omit<React.HTMLAttributes<HTMLDivElement>, "children">, the rest are spread on
the root and className is merged through cn().
- Required: subject { name, email, id }, actor { name },
startedAt, expiresAt (each string | number | Date, normalised by one toInstant()
that returns number | null for anything non-finite),
mode: "read-only" | "read-write", onExit: () => void.
- Optional: onExpire?: () => void; exiting = false; warnBeforeSeconds = 300;
now?: instant (first paint only); locale?: string;
labels?: Partial<Labels> merged over a DEFAULT_LABELS bag of strings and
string-returning functions — every visible word and every announcement goes
through it, so the component can be translated without a fork.
- Export the phase union "active" | "ending" | "expired". It is derived from the
clock, never accepted as a prop: two sources for one truth is how a bar ends up
claiming a session is live after it lapsed.
- `mode` is REPORTED, not enforced. Document that: the banner renders the
capability the server granted, and shipping "read-only" while writes actually
succeed is the one failure this component cannot detect for you.
Behavior
- Layout is the feature. The root is `sticky top-0 z-50`, i.e. IN FLOW: it takes
up its own height and pushes the page down, and it stays pinned while its scroll
container moves. No fixed positioning, no ResizeObserver, no spacer, no
padding-top for the host to keep in sync.
- The clock lives in state, not in render. `nowMs === null` means "not read yet":
the server render and the first client frame print an em dash instead of a
number hydration would then contradict. Injecting `now` skips that frame; the
interval takes over at mount, and a changed `now` is applied during render
(adjust-state-on-props) so a stepped clock never paints the old value first.
- One interval, 500ms, recomputing `expiresAt − Date.now()` every tick — never
"last value − 1", which drops nearly every tick in a throttled background tab
and returns wrong. Also recompute on `visibilitychange`, because a suspended tab
can come back minutes stale. Cancel the rAF, clear the interval and remove the
listener on unmount AND before every re-arm.
- Re-arm the timer on (startedAt, expiresAt, warnBeforeSeconds, mode,
subject.name). Re-arming on mode is deliberate: a grant elevated from read-only
to read-write mid-session is new authority over someone else's data and has to
be announced again.
- Three announcements, each exactly once, into ONE permanently mounted sr-only
region that is EMPTY on the first paint (role="alert", aria-live="assertive",
aria-atomic): (1) a frame after mount — "You are now acting as X, anything you
save is written to their account, it ends automatically in 46 minutes"; (2) the
rising edge into the warning window; (3) the deadline passing. Assertive is the
right politeness here — silently acting as another person is the failure this
component exists to prevent. The ticking number never enters the region.
Mounting already inside the warning window consumes the escalation edge (the
opening line already carried the number); mounting already past the deadline
skips the welcome entirely and says the expired line instead.
- onExpire fires ONCE PER DEADLINE, not once per timer arm: key the "already
fired" flag on the expiresAt value itself, or elevating the mode of an
already-lapsed grant re-arms the effect, resets a per-arm flag and asks the
host to release the same window a second time. The banner never ends the
session itself — it does not own the grant.
- After the deadline the interval SLOWS to 30s instead of stopping. There is
nothing left to count down, but "Started N ago" is still counting up while the
operator sits in the account, and a number that freezes at the deadline is a
stale claim the bar keeps printing. It stops for real only when there is no
start instant to count from — then nothing on the bar can change.
- Expired state: the mode badge becomes "Expired", the consequence line stops
claiming anything about writes and says the window has closed, the icon swaps to
a shield, and the exit stays exactly where it was. A session whose exit vanishes
at expiry is a trap.
- The exit is never disabled, never hidden and never behind a confirm — it is the
SAFE direction, and friction on the way out of someone else's account is
backwards. While `exiting` is true it shows a spinner and "Returning…", and
repeat presses are swallowed by a guard inside the handler, using aria-disabled
rather than the native attribute (a natively disabled button leaves the tab
order and drops focus to <body>).
- Refusals instead of invented numbers: an unparsable expiresAt renders "No end
time set" and no countdown; an unparsable startedAt renders "Start time
unavailable"; the elapsed hairline is dropped unless expiresAt > startedAt.
Elapsed time is FLOORED ("less than a minute", never "1 min") — a bar may
understate how long someone has been inside an account, never overstate it.
- Absolute instants (started at / ends at) live only inside the Tooltip, which
Radix mounts on open — i.e. on the client, in the reader's own time zone.
Formatting them during SSR would print the server's zone and mismatch on
hydration. Wrap Intl.DateTimeFormat construction in try/catch with an
("en-US") fallback: a malformed locale tag throws RangeError, and a banner that
crashes on its way to saying "you are inside a customer's account" is the worst
outcome available.
Rendering & styling
- Semantic tokens only. read-only = border-primary/30 bg-primary/10 with a
bg-primary/15 text-primary icon plate; read-write = the same shape on
destructive; expired = destructive at a heavier weight. Body copy is
text-foreground, supporting copy text-muted-foreground, the id chip
bg-foreground/5 font-mono, the elapsed hairline bg-foreground/10 with a
tone-coloured fill.
- Contrast trap, since every chip here sits on an already-tinted surface: do NOT
stack a /10 destructive chip on a /10 destructive bar — `text-destructive` lands
around 3.9:1 in light mode. The mode badge takes a SOLID destructive fill with
background-coloured text (and an explicit dark:bg-destructive, or tailwind-merge
keeps the variant's dark:bg-destructive/20 and inverts the pair), and the
escalated countdown is carried by font-weight plus a destructive GLYPH — icons
are non-text and only owe 3:1 — while the digits stay on text-foreground.
- The same trap catches small MUTED text, not just tinted text. Two strings opt
out of text-muted-foreground and take text-foreground: the account-id chip
(11px, on a /5 fill over an already-tinted bar it measures ~3.5:1 in light
mode, and its whole job is to be read character by character and pasted into a
ticket) and the consequence line WHILE EXPIRED (muted copy on the heaviest
destructive/15 surface is the worst pair on the bar at ~3.4:1). Keep the chip's
/5 fill — it is the text colour that has to carry the contrast, not the fill.
- One row that wraps: icon plate → a `min-w-0 grow basis-60` column (headline +
email + mode badge / consequence line / id · staff · started · countdown) →
a shrink-0 solid Button. Use `grow`, NOT `flex-1`: the shorthand also writes
flex-basis: 0% and Tailwind emits it after `basis-*`, so the 15rem base size —
the thing that pushes the button onto its own line below ~480px — would be
silently discarded. Nothing truncates except the email.
- Countdown is tabular-nums so the row does not jitter; the hairline has NO width
transition, because returning from a suspended tab is a genuine jump and easing
it would animate a lie.
- The only motion is a pulsing warning glyph inside the warning window, with
motion-reduce:animate-none; with motion off every number and state still lands.
- Accessibility: role="region" + aria-label naming the subject; the countdown chip
is a real focusable button (it discloses, it does not act) carrying an
aria-label that spells the remaining time in words; focus-visible rings on both
controls; decorative glyphs aria-hidden.
Customization levers
- Placement: `sticky top-0` is one class on the root. Under a fixed app header,
pass top-[var(--header-h)] through className. If an ancestor with overflow or
transform breaks sticky, switch that class to `fixed inset-x-0 top-0` and give
the host a spacer of the same height — the component stays in one DOM node
either way.
- Density: px-3 py-2 text-sm is the only sizing. Drop the consequence line or the
metadata line for a one-line strip; both are single blocks in the column.
- Tone: TONE maps mode → { root, plate, meter }. Point read-only at
var(--chart-1..5) if your support palette is separate from primary/destructive,
or make both modes destructive if your policy is that any impersonation is loud.
- Escalation: warnBeforeSeconds sets the single threshold. For a milestone ladder
(30 / 10 / 5 minutes), keep a Set of consumed milestones next to
announcedEndingRef and announce each once.
- Wording and i18n: DEFAULT_LABELS holds every string plus spokenDuration();
route them through your t() and the state machine is untouched.
- Extra identity: the mode badge and the id chip are siblings — add a
ticket-number chip or a region flag next to them without touching layout.
- Exit semantics: keep onExit doing the server round trip and let the host unmount
the bar on success. If your policy requires a confirm, wrap the host's handler
in an AlertDialog — do not put one inside the banner, where it would delay the
safe direction.Concepts
- Capability reported, not enforced —
moderenders what the server granted; the bar never blocks a write itself. Saying "read-only" while writes actually succeed is the one failure it cannot detect, so the consequence line spells out what the flag means instead of implying a guarantee. - Consequence before the action — the second line says where the next click lands ("written to Nadia Okafor's account, as if they did it") rather than labelling the session and leaving the reader to infer it; at expiry that claim is withdrawn instead of left standing.
- In-flow pinning —
sticky top-0occupies its own height, so the app's first row starts below the bar and still slides under it on scroll: no fixed overlay covering a header, no measured spacer for the host to keep in sync. - Assertive mount announcement — a permanently mounted, empty live region is filled one frame after paint, so acting as another person is spoken, not silent; the ticking countdown never enters it, and the escalation and expiry edges each fire exactly once.
- Always-live exit — the way back to your own account is never disabled, never confirmed and never removed, including after expiry. Friction belongs on the way into someone else's account, not on the way out.
- Refusal over a plausible number — an unreadable deadline prints "No end time set" and no clock, an inverted window drops the elapsed hairline, and elapsed time is floored, because a bar that understates a session is recoverable and one that invents a number is not. Past the deadline the clock slows to 30s rather than stopping: "Started 41 min ago" frozen at the moment the grant lapsed is the same invented number by another route.
Retry Countdown
A backoff card that derives every wait from the schedule you pass — attempt against the cap, a ring draining to the next automatic attempt, a retry-now that skips the wait, the reason the last attempt failed, and a terminal state at the cap that offers your escape hatch instead of a dead button.
Partial Failure Summary
The receipt for one bulk action that half worked — counts as a sentence, failures grouped by cause, and a retry scoped by whether the successes were committed or rolled back.