Skip to contentVibraUI
Inputs & filters

Social auth buttons

Sign-in buttons for Google, GitHub, Apple and Microsoft — the name, no logo — each with its own pending state.

Each button runs its own pending state off whatever onSelect returns, through AsyncButton: a slow redirect spins the button the reader pressed and leaves the others alone, which is the only honest thing to show while one of four requests is in flight. No mark is drawn by default — not a provider's logo, and not a look-alike of one: the button says where it signs in, which is the one thing it has to say. icons takes a mark per provider: drop in the provider's official mark under its brand terms, and it is drawn before the name and hidden from assistive technology, since the name already says it. Every button is one width with its label centred, stacked or in a row: a row is a grid of equal columns, as many to a line as hold "Continue with Microsoft" beside an icon (13rem), with the rest wrapping, and no column is wider than the row — so a phone gets a column instead of buttons spilling off the page. The label is a function of the provider, so a page that says "Sign up with" rather than "Continue with" changes one prop instead of four strings. onSelect is the provider that was chosen rather than the DOM select event, so it replaces the div prop of that name rather than intersecting with it.

Install

npx shadcn@latest add @vibra/social-auth-buttons

Needs the @vibra registry in your components.json — set it up once.

Examples

With icons

Neutral stand-ins where each provider's official mark goes.

Props

PropTypeDefaultDescription
providers("google" | "github" | "apple" | "microsoft")[]—Which buttons to render, in this order.
onSelect(provider) => void | Promise<void>—Return a promise and that one button holds its spinner until the work settles.
layout"stack" | "row""stack"A full-width column, or a row of equal columns that wraps into as many lines as it needs and stacks on a phone.
label(provider) => string—Rewrites a button's label; the default is "Continue with GitHub".
iconsPartial<Record<provider, React.ReactNode>>—A mark per provider, an SVG or an image, drawn before its name at the icon size; none by default. Drop in the provider's official mark under its brand terms.

Dependencies

Source

components/ui/social-auth-buttons.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { AsyncButton } from "@/components/ui/async-button"

export type SocialAuthProvider = "google" | "github" | "apple" | "microsoft"

/** How each provider writes its own name, which is what the button says. */
const PROVIDER_NAMES: Record<SocialAuthProvider, string> = {
  google: "Google",
  github: "GitHub",
  apple: "Apple",
  microsoft: "Microsoft",
}

// `onSelect` is the provider that was chosen, not the DOM select event, so it
// replaces the div prop of the same name rather than intersecting with it.
export type SocialAuthButtonsProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
  providers: SocialAuthProvider[]
  /** Return a promise and that one button holds its spinner until the work settles. */
  onSelect: (provider: SocialAuthProvider) => void | Promise<void>
  layout?: "stack" | "row"
  /** Rewrites a button's label; the default is "Continue with GitHub". */
  label?: (provider: SocialAuthProvider) => string
  /**
   * A mark to draw before a provider's name, per provider, at the icon size
   * whether it is an SVG or an image. None is drawn by default: the kit ships
   * no one's logo, not even a look-alike. Drop in the provider's official mark
   * under its brand terms.
   */
  icons?: Partial<Record<SocialAuthProvider, React.ReactNode>>
}

/**
 * The row of third-party sign-in buttons, one per provider.
 *
 * Each button runs its own pending state off whatever `onSelect` returns, so a
 * slow redirect spins the button the reader pressed and leaves the others
 * alone — which is the only honest thing to show when one of four requests is
 * in flight.
 *
 * A button says where it signs in and draws nothing else unless `icons` hands
 * it the provider's own mark. The buttons are one width and their labels
 * centred, stacked or in a row: a row is a grid of equal columns, as many to a
 * line as hold "Continue with Microsoft" beside an icon (13rem), and the rest
 * wrap — no column is ever wider than the row, so on a phone the row is a
 * column rather than buttons spilling off the page.
 */
function SocialAuthButtons({
  className,
  providers,
  onSelect,
  layout = "stack",
  label,
  icons,
  ...props
}: SocialAuthButtonsProps) {
  return (
    <div
      data-slot="social-auth-buttons"
      data-layout={layout}
      className={cn(
        "grid w-full gap-2",
        // A stack is one column no wider than the box, whatever its labels.
        layout === "row" ? "grid-cols-[repeat(auto-fit,minmax(min(100%,13rem),1fr))]" : "grid-cols-1",
        className
      )}
      {...props}
    >
      {providers.map((provider) => {
        const icon = icons?.[provider]
        return (
          <AsyncButton
            key={provider}
            data-slot="social-auth-button"
            data-provider={provider}
            variant="outline"
            className="w-full"
            onClick={() => onSelect(provider)}
          >
            {icon ? (
              <span
                data-slot="social-auth-icon"
                aria-hidden="true"
                className="inline-flex shrink-0 [&_img]:size-4 [&_svg]:size-4"
              >
                {icon}
              </span>
            ) : null}
            {label?.(provider) ?? `Continue with ${PROVIDER_NAMES[provider]}`}
          </AsyncButton>
        )
      })}
    </div>
  )
}

export { SocialAuthButtons }