Oauth Buttons
A third-party sign-in row whose brand colours arrive as props — one provider goes in flight, the rest lock without ever going natively disabled, and the last-used one stays marked.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/oauth-buttons.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "OAuthButtons" component (lucide-react for
the spinner and the alert glyph — no other runtime dependency, no popover, no
form library).
Contract
- forwardRef<HTMLDivElement, OAuthButtonsProps> extending
React.HTMLAttributes<HTMLDivElement>; remaining props and className land on the
root column and are merged with cn().
- providers: {
id: string, // unique: React key, in-flight key, error owner
label: string, // provider name only — "Google"
icon?: ReactNode, // the brand mark, supplied by the consumer
onSelect: () => void | Promise<void>, // REQUIRED
brand?: { background?, foreground?, border? },
disabled?: boolean, disabledReason?: string
}[]
- onSelect is required on purpose: a social button that renders pressable and
does nothing is the most common lie on a sign-in screen. Its return type is the
whole in-flight protocol — a promise keeps this button spinning and the rest
locked until it settles (popup round trip, or a redirect that never resolves
because the document goes away); a void return is a synchronous handover
(location.assign) and releases the guard immediately.
- brand is the ONE documented exception to token-only styling, and it is data,
not source: Google's white and GitHub's black are trademarked assets with usage
guidelines, they must not follow the host theme, and a copy baked into a
component library goes stale the moment a provider restyles it. Accept plain
CSS colour strings — including var(--brand-google-bg), which is how a consumer
gets a light and a dark brand surface without this component knowing about
themes — and paint them inline. Hardcode no colour anywhere in the file. For
the same trademark reason ship no logos: `icon` is a slot.
- Layout: layout?: "stack" | "grid" (default "stack"), iconOnly?: boolean.
- Copy: labelPrefix (default "Continue with "), lastUsedLabel (default
"Last used"), dividerLabel (default "or continue with email", null removes the
divider), label (group's accessible name), errorText and slowText, both
interpolating "{provider}".
- State the host may own: lastUsedId?: string | null, pendingId?: string | null,
error?: { providerId?, message } | null. Each is controlled when the prop is
passed (undefined = this component owns it, null = "explicitly nothing").
- lastUsedId is a prop, never internal state: reading localStorage during render
is a hydration mismatch, so the host hydrates it in an effect.
- onProviderError?(error, providerId) fires on every rejection, mounted or not —
telemetry must not depend on whether the tree survived.
Behavior
- One press wins. A pendingRef is read AND written synchronously inside the
click handler, before onSelect can await: a double click fires two events in
one tick, before React re-renders, so a state-only guard lets both through and
sends two authorization requests. The ref is the gate; state is only paint.
- Attempt token: increment a counter alongside the ref and capture it in the
closure; a promise from a superseded attempt (the host reset pendingId, the
card remounted) settles into nothing instead of clearing a newer spinner.
- In flight, exactly one button is aria-busy with the spinner swapped into its
icon slot; every other button becomes aria-disabled — and aria-disabled ONLY.
Never the native attribute and never pointer-events:none: the browser blurs a
control the instant it is disabled, so locking the row while a keyboard user
is on it drops focus to <body>. The guard lives in the handler, so a locked
button stays hoverable, focusable and announced ("unavailable while signing in
with Google"). A per-provider `disabled` behaves the same way.
- Failure has two entrances and one exit. A rejected promise and a redirect that
comes back with ?error=access_denied (fed in through the controlled `error`
prop) render the same role="alert" under the row; the owning button points at
it with aria-describedby, so a screen-reader user tabbing back still hears why
it failed. The message is the thrown Error's own text when it has one,
otherwise errorText with {provider} filled in.
- Focus after an internal rejection returns to the button that failed, so the
retry is one keystroke — but only if document.activeElement is still inside
the root (or is <body>). Yanking focus away from whatever the visitor moved on
to is hostile, and a controlled `error` present at mount must never steal
focus at all.
- Slow hint: once an attempt outlives slowAfterMs (default 6000, 0 disables) show
"Still waiting for Google. If a pop-up window was blocked…". One timer, armed
in an effect keyed on [pending, slowAfterMs], cleared on every dependency
change and on unmount; the visible line is aria-hidden because the live region
already speaks it.
- Announcements: one permanently mounted sr-only role="status" carries "Signing
in with Google…" and then the slow text. Permanently mounted matters — a live
region inserted at the same moment as its text is unreliable in most screen
readers — and it is the only announcer, so nothing is read twice.
- Keyboard map: deliberately native. Each provider is its own Tab stop, Enter and
Space activate it, and there is no roving tabindex and no arrow-key handling.
A radiogroup/menu pattern would be wrong here: nothing is "selected", these are
N independent destinations, and stealing arrow keys from a sign-in column
surprises everyone. The discipline is in what stays focusable while locked, not
in intercepting keys.
- Accessible name per button = "{labelPrefix}{label}" + ", Last used" + one of
", signing in" / ", {disabledReason ?? unavailable}" / ", unavailable while
signing in with {pending}". The visible label is the prefix of that string
(WCAG 2.5.3), so voice control still activates "Continue with Google". In
iconOnly mode the same string is the only name there is.
- Degenerate cases: providers=[] renders null — a lone "or continue with email"
divider under nothing is worse than an empty box; a lastUsedId or an
error.providerId matching nothing renders no hint and no ownership; a provider
with no icon still gets its fixed-size slot so labels stay aligned, and in
iconOnly mode falls back to the label's first grapheme instead of a blank tile;
duplicate ids are the consumer's bug (they are React keys).
- Cleanup: the slow timer is cleared on unmount and on dependency change; a
mountedRef (re-armed in the effect body, not just initialised, because
StrictMode mounts twice) guards every setState behind an await; promise
handlers check the attempt token first.
Rendering & styling
- Everything except `brand` is a semantic token: bg-background + border with
hover:bg-accent hover:text-accent-foreground for the neutral skin,
text-muted-foreground for the divider and hint copy, text-destructive for the
alert, bg-border for the divider rules, ring-ring for focus. Tokens are also
how the row gets dark mode for free — the only theme-independent pixels on it
are the ones the consumer paid for with `brand`.
- ARIA skeleton: root column (consumer className, remaining props) > div
role="group" aria-label={label} holding the buttons > the alert paragraph >
the divider > a permanently mounted sr-only role="status". role="group", not
radiogroup/toolbar/list: nothing here is selected and nothing is a set of
peers to arrow between.
- Branded buttons cannot take hover tokens without losing the brand, and a
filter is no help either — brightness() can neither lighten black nor darken
white. Use a currentColor state layer instead: an absolutely positioned,
pointer-events-none ::before with before:bg-current, opacity 0 → 0.1 on hover
and back to 0 while aria-disabled. It is the button's own ink, so it reads as
a shade over a dark brand and a tint over a light one, on any surface. The
label, the mark slot and the last-used pill are position:relative so they
paint above that layer — otherwise hover would tint a trademarked logo.
- The focus ring uses ring-offset-2 ring-offset-background so it reads against a
surface this component knows nothing about.
- layout="stack": h-10 full-width rows, mark then label, last-used as a
border-current pill pushed out with ml-auto. layout="grid": auto-fit tiles via
grid-template-columns: repeat(auto-fit, minmax(min(9rem,100%),1fr)) — 3.25rem
when iconOnly — and the last-used hint becomes a bg-current dot, because a pill
does not fit a tile.
- The icon slot is a fixed size-5 box that is always rendered, so swapping the
spinner in costs no reflow and rows with and without a mark still line up.
- Reduced motion: the only motion is the spinner (motion-reduce:animate-none),
the colour transition (motion-reduce:transition-none) and the state layer's
fade (motion-reduce:before:transition-none) — all three degrade to an instant
swap. With motion off the row still locks, still announces and still fails the
same way: aria-busy and the live region carry the state, not the animation.
Customization levers
- Copy and locale: labelPrefix ("Sign in with ", "Continue with ", or "" for
bare provider names), dividerLabel (null removes the divider entirely, e.g.
when the row sits under the email form instead of above it), lastUsedLabel,
errorText and slowText — the last two interpolate {provider}.
- Order and count: providers is data. Two entries and no divider is a modal;
five plus an SSO row is an enterprise login. Leave the order stable rather than
promoting the last-used provider to the top — a row that reshuffles under the
cursor is how people sign in with the wrong account.
- Density: h-10 rows with gap-2 is the default; h-9 / gap-1.5 tightens a modal,
h-11 is comfortable on touch. The grid's minmax floor (9rem / 3.25rem) is the
only number to touch when tiles wrap too early or too late.
- Skins: pass no `brand` at all for a row that is pure host tokens, light and
dark (the right default for enterprise SSO, which has no brand to honour); pass
`brand` per provider to follow official guidelines; pass CSS variables as the
brand values when the brand itself has a dark variant.
- Ownership: leave pendingId and error off for the self-driving component, or
pass both when your auth layer owns the round trip (redirect flows always do —
the failure arrives on the URL, not from a promise).
- Slow hint: raise slowAfterMs for a redirect-heavy flow where a long wait is
normal, or set 0 to remove it; rewrite slowText if popups are not how your
provider works.Concepts
- Brand paint as data — the row is token-only everywhere except one prop. Provider colours are trademarked assets that must not follow the host theme, so they arrive as CSS strings and are painted inline; the mark arrives as a slot for the same reason, which is why this component bundles no logos and never goes stale when a provider restyles.
- One press wins — the in-flight gate is a ref read and written inside the handler, before
onSelectcan await. A double click fires two events in one tick, before React re-renders, so a state-only guard sends two authorization requests; an attempt counter additionally makes a superseded promise settle into nothing. - Locking without disabling — while one provider is in flight the others go
aria-disabled, never nativedisabledand neverpointer-events:none. The browser blurs a control the instant it is disabled, so the native attribute would drop a keyboard user on<body>mid-flow; here the guard lives in the handler and the buttons stay focusable and announced. - Two entrances, one alert — a rejected promise and a redirect that comes back with
?error=access_deniedland in the samerole="alert", owned by one button througharia-describedby. Redirect flows have no promise to reject, which is exactly why the error is also accepted as a prop. - Last used is a hint, not a reordering — the marker tells a returning visitor which identity they used without moving anything; a row that reshuffles under the cursor is how people sign in with the wrong account. The value is a prop because reading it from storage during render is a hydration mismatch.
- The silence that needs a hint — an OAuth popup blocked by the browser produces no event at all, so the only honest signal is elapsed time: past
slowAfterMsthe row says so, in a live region that was mounted long before it had text to say.
Export Button
An export menu for a data table — row scope, a column subset, a live size estimate on every format, and a row that generates, ticks or turns into Retry without the menu closing under it.
Rating
A star rating that is both an accessible input (whole or half steps) and a fractional read-only display.