@cantera/provider-sign-in-buttoncomponent
A sign-in button and link for a single OAuth provider: brand icon, label, loading state. ProviderSignInButton handles a click; ProviderSignInLink navigates to an auth route.
npx shadcn@latest add @cantera/provider-sign-in-button| Prop | Type | Default | Description |
|---|---|---|---|
| provider | OAuthProvider | — | The provider to render: id, name, and an optional brand icon. |
| onSignIn | () => void | Promise<void> | — | Called with no arguments on click. A returned promise drives the pending state for you. For navigation to an auth route, use ProviderSignInLink instead. |
| loading | boolean | false | Pending: the label stays, the icon crossfades to a spinner over 150ms, and activation is blocked via aria-disabled so focus is never dropped. |
| disabled | boolean | false | Rendered as aria-disabled, never the native attribute, so the control keeps focus and a screen reader user can still find it. |
| variant | 'default' | 'outline' | 'secondary' | 'ghost' | 'outline' | Button variant, forwarded to the shadcn button styles. |
| size | 'default' | 'sm' | 'lg' | 'lg' | Button size. Everything but sm carries the 44px minimum touch target; sm is the opt-in compact escape hatch. |
| children | ReactNode | 'Continue with {provider.name}' | Custom label replacing the default text. |
| ...props | ComponentProps<'button'> | — | Remaining props go to the button element and are typed for it. |
| Prop | Type | Default | Description |
|---|---|---|---|
| provider | OAuthProvider | — | The provider to render: id, name, and an optional brand icon. |
| href | string | — | The provider auth route to navigate to. Always renders an anchor — loading included — so the browser semantics of a link are never lost. |
| loading | boolean | false | Pending: the label stays, the icon crossfades to a spinner over 150ms, and navigation is blocked via aria-disabled so focus is never dropped. |
| disabled | boolean | false | Rendered as aria-disabled, never the native attribute, so the control keeps focus and a screen reader user can still find it. |
| variant | 'default' | 'outline' | 'secondary' | 'ghost' | 'outline' | Button variant, forwarded to the shadcn button styles. |
| size | 'default' | 'sm' | 'lg' | 'lg' | Button size. Everything but sm carries the 44px minimum touch target; sm is the opt-in compact escape hatch. |
| children | ReactNode | 'Continue with {provider.name}' | Custom label replacing the default text. |
| ...props | ComponentProps<'a'> | — | Remaining props go to the anchor element and are typed for it. |
This is the exact code the CLI installs into your project — you own it from there.
'use client'
import { LoaderCircleIcon } from 'lucide-react'
import type * as React from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { OAuthProvider } from '@/lib/oauth-types'
import { cn } from '@/lib/utils'
interface ProviderSignInBaseProps {
provider: OAuthProvider
loading?: boolean
/** Rendered as `aria-disabled`, never the native attribute, so the control
* keeps focus and stays discoverable to a screen reader. */
disabled?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
size?: 'default' | 'sm' | 'lg'
}
type ProviderSignInLinkProps = ProviderSignInBaseProps &
Omit<React.ComponentProps<'a'>, 'href'> & {
href: string
}
type ProviderSignInButtonProps = ProviderSignInBaseProps &
Omit<React.ComponentProps<'button'>, 'disabled'> & {
/** A returned promise drives the pending state until it settles. */
onSignIn?: () => void | Promise<void>
}
/** Thenable check, not `instanceof Promise`: a polyfilled or cross-realm
* promise is still a pending round trip the button must reflect. */
function isPromiseLike(value: void | Promise<void>): value is Promise<void> {
return value != null && typeof (value as Promise<void>).then === 'function'
}
function ProviderSignInIcon({ provider, loading }: { provider: OAuthProvider; loading: boolean }) {
return (
<span aria-hidden className="grid size-4 shrink-0 place-items-center">
{/* The spin lives on a wrapper: transform animations on the <svg> itself
skip the compositor in some engines. */}
<span className="col-start-1 row-start-1 grid size-4 animate-spin place-items-center">
<LoaderCircleIcon
className={cn(
'size-4 transition-opacity duration-150 ease-out',
loading ? 'opacity-100' : 'opacity-0',
)}
/>
</span>
{provider.icon && (
<span
className={cn(
'col-start-1 row-start-1 flex transition-opacity duration-150 ease-out',
'[&_svg]:size-4 [&_svg]:shrink-0',
loading ? 'opacity-0' : 'opacity-100',
)}
>
{provider.icon}
</span>
)}
</span>
)
}
function providerSignInClasses(
size: NonNullable<ProviderSignInBaseProps['size']>,
disabled: boolean,
pending: boolean,
className: string | undefined,
): string {
return cn(
'w-full justify-center gap-2',
// 44px minimum touch target; `size="sm"` is the opt-in compact escape hatch.
size !== 'sm' && 'min-h-11',
'aria-disabled:pointer-events-none',
disabled && !pending && 'opacity-50',
className,
)
}
function ProviderSignInLink(props: ProviderSignInLinkProps) {
const {
provider,
loading = false,
disabled = false,
variant = 'outline',
size = 'lg',
className,
children,
href,
...anchorProps
} = props
const inert = loading || disabled
return (
<Button
// The anchor stays an anchor while loading; aria-disabled blocks the
// pointer and the Enter key without dumping focus.
render={<a {...anchorProps} href={href} />}
nativeButton={false}
// The primitive assumes button semantics for a non-native element; this
// is a navigation link, so keep the link role it earns from href.
role="link"
disabled={inert}
focusableWhenDisabled
data-slot="provider-sign-in-button"
aria-busy={loading || undefined}
variant={variant}
size={size}
className={providerSignInClasses(size, disabled, loading, className)}
>
<ProviderSignInIcon provider={provider} loading={loading} />
{children ?? `Continue with ${provider.name}`}
</Button>
)
}
/** For server-rendered flows that navigate to an auth route, use
* `ProviderSignInLink` instead. */
function ProviderSignInButton(props: ProviderSignInButtonProps) {
const {
provider,
loading = false,
disabled = false,
variant = 'outline',
size = 'lg',
className,
children,
onSignIn,
...buttonProps
} = props
const [asyncPending, setAsyncPending] = useState(false)
const pending = loading || asyncPending
const inert = pending || disabled
return (
<Button
type="button"
{...buttonProps}
disabled={inert}
focusableWhenDisabled
data-slot="provider-sign-in-button"
aria-busy={pending || undefined}
variant={variant}
size={size}
className={providerSignInClasses(size, disabled, pending, className)}
onClick={(event) => {
buttonProps.onClick?.(event)
const result = onSignIn?.()
if (!isPromiseLike(result)) return
setAsyncPending(true)
result.then(
() => setAsyncPending(false),
() => setAsyncPending(false),
)
}}
>
<ProviderSignInIcon provider={provider} loading={pending} />
{children ?? `Continue with ${provider.name}`}
</Button>
)
}
export {
ProviderSignInButton,
type ProviderSignInButtonProps,
ProviderSignInLink,
type ProviderSignInLinkProps,
}