Buttons

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { LoaderCircle, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Brand paint for one provider — the single documented exception to this
 * library's token-only rule, and the reason it is *data* rather than source.
 *
 * Google's white, GitHub's black and Apple's black are trademarked assets with
 * usage guidelines attached. They are not part of anybody's design system, they
 * must not follow the host theme, and a copy baked into a component library goes
 * stale the moment a provider restyles it. So they arrive as plain CSS colour

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/oauth-buttons.json

Prompt

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 onSelect can 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 native disabled and never pointer-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_denied land in the same role="alert", owned by one button through aria-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 slowAfterMs the row says so, in a live region that was mounted long before it had text to say.

On This Page