Provider sign-in buttons with a gliding Last used badge and per-button loading.
Auth & onboardingmotion
01Preview
Sign in to Halcyon
Pick up where you left off on the Q3 roadmap.
Work account
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { SocialButtons } from "@/components/ui/social-buttons";
<SocialButtons
providers={["google", "github", "apple"]}
lastUsed={cookies.lastProvider}
onSignIn={(provider) => signIn(provider)}
/>04Source
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Alert } from "@/lib/icons";
import { ease, spring, swap } from "@/lib/motion";
export type SocialProvider = "google" | "github" | "apple" | "microsoft" | "sso";
const names: Record<SocialProvider, string> = {
google: "Google",
github: "GitHub",
apple: "Apple",
microsoft: "Microsoft",
sso: "SSO",
};
const messageOf = (error: unknown, fallback: string) =>
error instanceof Error && error.message ? error.message : typeof error === "string" && error ? error : fallback;
/**
* Provider marks, one color, drawn to sit at 16px beside text. Brand glyphs are
* filled on a 24-unit box; SSO is a stroked key on the Stealth 16px grid.
*/
export function ProviderGlyph({ provider, size = 16, className }: { provider: SocialProvider; size?: number; className?: string }) {
const common = { width: size, height: size, "aria-hidden": true, focusable: false, className } as const;
switch (provider) {
case "google":
return (
<svg {...common} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>
);
case "github":
return (
<svg {...common} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
case "apple":
return (
<svg {...common} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
);
case "microsoft":
return (
<svg {...common} viewBox="0 0 24 24" fill="currentColor">
<path d="M1.5 1.5h10v10h-10zM12.5 1.5h10v10h-10zM1.5 12.5h10v10h-10zM12.5 12.5h10v10h-10z" />
</svg>
);
case "sso":
return (
<svg {...common} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round">
<circle cx="5.5" cy="10.5" r="2.75" />
<path d="m7.5 8.5 5.75-5.75M11 5l1.75 1.75M9.5 6.5l1.25 1.25" />
</svg>
);
}
}
export type SocialButtonProps = Omit<React.ComponentProps<"button">, "children"> & {
provider: SocialProvider;
/** Overrides the default "Continue with Google". */
label?: React.ReactNode;
loading?: boolean;
/** Marks the provider this person signed in with last time. */
lastUsed?: boolean;
lastUsedLabel?: string;
/** Give buttons in a group the same id and the badge glides to whichever one becomes last used. */
lastUsedLayoutId?: string;
/** Hide the label when the surrounding `@container/social` is narrower than 20rem. Name it with aria-label. */
compact?: boolean;
size?: "md" | "lg";
};
/** One provider button. The glyph slot turns into a spinner while loading, so nothing around it moves. */
export function SocialButton({
provider,
label,
loading = false,
lastUsed = false,
lastUsedLabel = "Last used",
lastUsedLayoutId,
compact = false,
size = "lg",
disabled,
className,
onClick,
...rest
}: SocialButtonProps) {
const reduce = useReducedMotion();
const text = label ?? `Continue with ${names[provider]}`;
return (
<button
type="button"
data-provider={provider}
data-state={loading ? "loading" : "idle"}
data-size={size}
data-last-used={lastUsed ? "" : undefined}
aria-busy={loading || undefined}
aria-disabled={disabled || loading || undefined}
onClick={(e) => {
// Busy and disabled buttons stay focusable (so focus isn't dropped mid-redirect) but inert to presses.
if (disabled || loading) return e.preventDefault();
onClick?.(e);
}}
className={cn(
"group/social relative inline-flex w-full min-w-0 select-none items-center justify-center gap-2 rounded-lg border border-line-2 bg-raised px-3 font-medium tracking-[-0.005em] text-fg shadow-[var(--shadow)] outline-none",
"transition-[background-color,border-color,opacity,scale] duration-150 ease-out",
"hover:border-fg-4 hover:bg-hover active:scale-[0.98] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"data-[state=loading]:cursor-default data-[state=loading]:bg-hover data-[state=loading]:active:scale-100",
"aria-disabled:not-data-[state=loading]:pointer-events-none aria-disabled:not-data-[state=loading]:opacity-50",
size === "lg" ? "h-9 text-[13px] pointer-coarse:h-11" : "h-8 text-[12.5px] pointer-coarse:h-10",
className,
)}
{...rest}
>
<span className="relative grid size-4 shrink-0 place-items-center">
<AnimatePresence initial={false}>
<motion.span
key={loading ? "spin" : "glyph"}
className="absolute inset-0 grid place-items-center"
initial={reduce ? { opacity: 0 } : swap.initial}
animate={swap.animate}
exit={reduce ? { opacity: 0 } : swap.exit}
transition={reduce ? { duration: 0.12 } : spring.pop}
>
{loading ? <Spinner /> : <ProviderGlyph provider={provider} size={provider === "sso" ? 16 : 15} />}
</motion.span>
</AnimatePresence>
</span>
<span className={cn("min-w-0 truncate", compact && "hidden @[20rem]/social:inline")}>{text}</span>
{lastUsed && <span className="sr-only">{` (${lastUsedLabel.toLowerCase()})`}</span>}
<AnimatePresence initial={false}>
{lastUsed && (
<motion.span
aria-hidden
layoutId={lastUsedLayoutId}
// Sits on the top edge like a tab, so it never crowds a centered label.
className="pointer-events-none absolute -top-1.5 right-2.5 inline-flex h-4 origin-bottom-right items-center rounded-full border border-line-2 bg-raised px-1.5 text-[10px] font-medium leading-none tracking-[0.01em] text-fg-2"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, y: 3 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, transition: { duration: 0.1 } }}
transition={reduce ? { duration: 0.15, layout: { duration: 0 } } : { ...spring.pop, layout: spring.soft }}
>
{lastUsedLabel}
</motion.span>
)}
</AnimatePresence>
</button>
);
}
export type SocialButtonsProps = Omit<React.ComponentProps<"div">, "children" | "onError"> & {
providers?: SocialProvider[];
/** Start sign-in with a provider. While the promise is pending that button spins and the others step back. */
onSignIn?: (provider: SocialProvider) => Promise<unknown> | unknown;
/** Controlled loading provider, for redirects you track yourself. */
loading?: SocialProvider | null;
onLoadingChange?: (provider: SocialProvider | null) => void;
lastUsed?: SocialProvider | null;
lastUsedLabel?: string;
/** "list" stacks full-width "Continue with …" buttons; "grid" puts short names side by side. */
layout?: "list" | "grid";
size?: "md" | "lg";
disabled?: boolean;
onError?: (provider: SocialProvider, error: unknown) => void;
};
export function SocialButtons({
providers = ["google", "github", "apple"],
onSignIn,
loading: loadingProp,
onLoadingChange,
lastUsed = null,
lastUsedLabel = "Last used",
layout = "list",
size = "lg",
disabled = false,
onError,
className,
...rest
}: SocialButtonsProps) {
const reduce = useReducedMotion();
// Set from inside an await, so it can't compare against a value captured before the await.
const [innerLoading, setInnerLoading] = useState<SocialProvider | null>(null);
const loading = loadingProp !== undefined ? loadingProp : innerLoading;
const setLoading = (next: SocialProvider | null) => {
setInnerLoading(next);
onLoadingChange?.(next);
};
const [error, setError] = useState<{ provider: SocialProvider; message: string } | null>(null);
const [announce, setAnnounce] = useState("");
const ticket = useRef(0);
const badgeId = useId();
async function start(provider: SocialProvider) {
if (loading || disabled) return;
const mine = ++ticket.current;
setError(null);
setLoading(provider);
setAnnounce(`Connecting to ${names[provider]}…`);
try {
await onSignIn?.(provider);
} catch (err) {
if (mine !== ticket.current) return;
const message = messageOf(err, `Couldn’t connect to ${names[provider]}. Try again.`);
setError({ provider, message });
setAnnounce("");
onError?.(provider, err);
} finally {
// A resolved promise usually means the page is navigating away; clear anyway in case it isn't.
if (mine === ticket.current) setLoading(null);
}
}
return (
<div data-layout={layout} className={cn("flex w-full flex-col gap-2", className)} {...rest}>
<div
role="group"
aria-label="Sign in with"
// In a narrow grid the names drop out and the marks carry it; each button keeps its full accessible name.
className={cn(layout === "grid" ? "@container/social grid gap-2" : "flex flex-col gap-2")}
style={layout === "grid" ? { gridTemplateColumns: `repeat(${Math.min(providers.length, 3)}, minmax(0, 1fr))` } : undefined}
>
{providers.map((p) => (
<SocialButton
key={p}
provider={p}
size={size}
label={layout === "grid" ? names[p] : undefined}
compact={layout === "grid"}
aria-label={layout === "grid" ? `Continue with ${names[p]}${lastUsed === p ? ` (${lastUsedLabel.toLowerCase()})` : ""}` : undefined}
loading={loading === p}
disabled={disabled || (!!loading && loading !== p)}
lastUsed={lastUsed === p}
lastUsedLabel={lastUsedLabel}
lastUsedLayoutId={`${badgeId}-last`}
onClick={() => start(p)}
/>
))}
</div>
<AnimatePresence initial={false}>
{error && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0.1 : 0.16, ease: ease.in } }}
transition={reduce ? { duration: 0.12, height: { duration: 0 } } : { duration: 0.22, ease: ease.out }}
className="overflow-hidden"
>
<p role="alert" className="flex items-start gap-1.5 pt-0.5 text-[12px] leading-4 text-danger">
<Alert size={14} className="mt-px shrink-0" />
<span className="min-w-0 text-pretty">{error.message}</span>
</p>
</motion.div>
)}
</AnimatePresence>
<span role="status" aria-live="polite" className="sr-only">
{announce}
</span>
</div>
);
}
function Spinner() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden className="animate-spin [animation-duration:0.7s] motion-reduce:[animation-duration:1.6s]!">
<circle cx="8" cy="8" r="5.75" stroke="currentColor" strokeOpacity="0.2" strokeWidth="1.5" />
<path d="M13.75 8A5.75 5.75 0 0 0 8 2.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}05Props
SocialButtons
| Prop | Type | Default | Description |
|---|---|---|---|
| providers | ("google" | "github" | "apple" | "microsoft" | "sso")[] | ["google", "github", "apple"] | Which buttons, in order. |
| onSignIn | (provider) => Promise<unknown> | — | Start sign-in. That button spins until the promise settles; a rejection shows its message under the group. |
| lastUsed | SocialProvider | null | null | Marks one provider with the Last used badge. Change it and the badge glides across. |
| lastUsedLabel | string | "Last used" | Badge text, also appended to the button’s accessible name. |
| layout | "list" | "grid" | "list" | Stacked “Continue with …” buttons, or short names side by side that drop to marks when narrow. |
| size | "md" | "lg" | "lg" | 32px or 36px tall; both grow to 40–44px on touch screens. |
| loading | SocialProvider | null | — | Controlled loading provider, for redirects you track yourself. |
| onLoadingChange | (provider | null) => void | — | Called when a button starts or stops loading. |
| disabled | boolean | false | Steps every button back. |
| onError | (provider, error) => void | — | Called with the rejection, for logging. |
SocialButton
| Prop | Type | Default | Description |
|---|---|---|---|
| provider* | SocialProvider | — | Picks the mark and the default label. |
| label | ReactNode | — | Overrides “Continue with Google”. |
| loading | boolean | false | Swaps the mark for a spinner in the same 16px slot. |
| lastUsed | boolean | false | Shows the badge on the top edge. |
| lastUsedLayoutId | string | — | Share one id across buttons so the badge glides between them. |
| compact | boolean | false | Hide the label when the surrounding @container/social is under 20rem. |
| size | "md" | "lg" | "lg" | 32px or 36px tall. |
ProviderGlyph
| Prop | Type | Default | Description |
|---|---|---|---|
| provider* | SocialProvider | — | One-color mark in currentColor, drawn inline. |
| size | number | 16 | Width and height in pixels. |
06Notes
Behavior
- Only one provider can be in flight: while one spins the others step back to 50% and ignore presses, because two sign-in redirects at once is never what anyone meant.
- A rejected promise (the popup was closed, the provider timed out) clears the spinner and shows the message under the group, in place; the next press clears it.
- The badge sits on the button’s top edge like a tab, so it never collides with a centered label, however long the translation.
- In the grid layout the names give way to the marks when the group is narrower than 20rem; every button keeps its full accessible name.
Motion
- The mark and the spinner swap in place on the pop spring, scaling from 0.6 with a 3px blur, so the label never shifts.
- When the last used provider changes, the badge glides to its new button on the soft spring (a shared layout id) instead of blinking out and in.
- Press scales to 0.98 in 75ms; the stepped-back buttons fade in 150ms. Reduced motion keeps the fades and drops scale, blur and the glide.
Accessibility
- Native buttons in a group labelled “Sign in with”. The badge is hidden from assistive tech and folded into the name instead: “Continue with GitHub (last used)”.
- Busy and stepped-back buttons use aria-busy and aria-disabled rather than disabled, so focus is never dropped mid-sign-in.
- A polite live region says “Connecting to GitHub…”; errors are announced with role alert.