Social Login Buttons
The separator rules draw outward from their label, then the provider buttons rise in one after the next.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Social Login Buttons
*
* The separator rules draw outward from their label, then the provider
* buttons rise into place one after the next.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The provider glyphs are neutral shapes authored here — swap them for
* your own provider marks when you wire this up.
* Works with zero props; tune via `variant`, `providers`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SocialLoginProvider = {
/** Button label, e.g. "Continue with Provider A". */
label: string;
/** Neutral glyph shape. Replace with your provider's mark. */
glyph?: "orbit" | "split" | "prism";
};
export type SocialLoginButtonsProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Text between the two rules. */
dividerLabel?: string;
/** Buttons rendered under the divider, in order. */
providers?: SocialLoginProvider[];
/** Primary button color. */
accent?: string;
/** Fires with the label of whichever provider was pressed. */
onSelect?: (label: string) => void;
};
type VariantConfig = {
/** Seconds the rules take to reach full width. */
rule: number;
/** Travel before a provider button lands, in px. */
rise: number;
/** Gap between consecutive buttons. */
stagger: number;
/** Beat after the rules before the first button moves. */
lead: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: these buttons carry text, so they translate and fade and
// nothing else — no scale, no rebound. Every spring sits above a 0.8
// damping ratio, and variants differ only in travel and tempo. The
// divider is the one element that changes size, and it is a 1px rule
// with no glyphs on it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a plain fade. For sign-in screens people see every morning.
subtle: {
rule: 0.24,
rise: 7,
stagger: 0.045,
lead: 0.06,
spring: { type: "spring", stiffness: 540, damping: 46 },
},
// The rules read as drawing and the list reads as a list. The
// all-purpose setting.
default: {
rule: 0.32,
rise: 12,
stagger: 0.07,
lead: 0.1,
spring: { type: "spring", stiffness: 440, damping: 38 },
},
// Longer travel and a wider gap, so the options visibly arrive as
// choices rather than as a block.
playful: {
rule: 0.4,
rise: 18,
stagger: 0.095,
lead: 0.14,
spring: { type: "spring", stiffness: 380, damping: 32 },
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface or border correctly toned in
* either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_PROVIDERS: SocialLoginProvider[] = [
{ label: "Continue with Provider A", glyph: "orbit" },
{ label: "Continue with Provider B", glyph: "split" },
{ label: "Continue with Provider C", glyph: "prism" },
];
/** Deliberately generic marks: three shapes that are clearly placeholders
* for whatever identity providers you actually support. */
function ProviderGlyph({ glyph }: { glyph: SocialLoginProvider["glyph"] }) {
return (
<svg
aria-hidden
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
{glyph === "split" ? (
<>
<rect x="2.2" y="2.2" width="11.6" height="11.6" rx="3.2" />
<path d="M5 11 11 5" />
</>
) : glyph === "prism" ? (
<>
<path d="M8 2.4 13.8 13H2.2z" />
<circle cx="8" cy="9.6" r="1.5" />
</>
) : (
<>
<circle cx="8" cy="8" r="5.8" />
<circle cx="8" cy="5.4" r="1.7" />
</>
)}
</svg>
);
}
export default function SocialLoginButtons({
variant = "default",
dividerLabel = "or continue with",
providers = DEFAULT_PROVIDERS,
accent = "#5B5BD6",
onSelect,
}: SocialLoginButtonsProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion keeps the same order and the same DOM — the rules
// arrive at full width and the buttons fade where they already are.
const ruleTransition = reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: { duration: cfg.rule, ease: "easeOut" as const };
const buttonMotion = (index: number) =>
reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: {
duration: 0.18,
delay: 0.1 + index * 0.03,
ease: "easeOut" as const,
},
}
: {
initial: { opacity: 0, y: cfg.rise },
animate: { opacity: 1, y: 0 },
transition: {
...cfg.spring,
delay: cfg.rule + cfg.lead + index * cfg.stagger,
// Opacity runs on its own short curve; springing a fade
// leaves a long, muddy tail behind the movement.
opacity: {
duration: 0.2,
delay: cfg.rule + cfg.lead + index * cfg.stagger,
ease: "easeOut" as const,
},
},
};
return (
<div
style={{
width: 288,
display: "flex",
flexDirection: "column",
gap: 12,
padding: 20,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label
htmlFor="vibary-slb-email"
style={{ fontSize: 11.5, fontWeight: 600, opacity: 0.6 }}
>
Work email
</label>
<input
id="vibary-slb-email"
type="email"
autoComplete="email"
placeholder="you@company.com"
style={{
width: "100%",
boxSizing: "border-box",
padding: "9px 11px",
fontSize: 14,
fontFamily: "inherit",
color: "inherit",
borderRadius: 9,
border: `1px solid ${tone(16)}`,
background: tone(8),
}}
/>
</div>
<button
type="button"
style={{
width: "100%",
padding: "10px 14px",
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
cursor: "pointer",
}}
>
Continue
</button>
{/* The divider is one row of three cells. Each rule scales from the
edge nearest the label, so the pair reads as a single line being
drawn outward through the word rather than as two bars growing. */}
<div
aria-hidden
style={{
display: "flex",
alignItems: "center",
gap: 10,
margin: "2px 0",
}}
>
{(["right", "left"] as const).map((origin, index) => (
<motion.span
key={origin}
initial={{ scaleX: reduceMotion ? 1 : 0, opacity: 0 }}
animate={{ scaleX: 1, opacity: 1 }}
transition={ruleTransition}
style={{
order: index === 0 ? 0 : 2,
flex: 1,
height: 1,
background: tone(16),
transformOrigin: origin,
}}
/>
))}
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ order: 1, fontSize: 11.5, whiteSpace: "nowrap" }}
>
{dividerLabel}
</motion.span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{providers.map((provider, index) => (
<motion.button
key={provider.label}
type="button"
onClick={() => onSelect?.(provider.label)}
{...buttonMotion(index)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "9px 12px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: tone(5),
border: `1px solid ${tone(14)}`,
borderRadius: 9,
cursor: "pointer",
textAlign: "left",
}}
>
<ProviderGlyph glyph={provider.glyph} />
{provider.label}
</motion.button>
))}
</div>
</div>
);
}About this pattern
The block of alternatives under a sign-in field, sequenced so it reads as a set of choices rather than a wall. The two rules either side of the label scale from the edge nearest the word, which makes the pair read as one line being drawn outward through it — a beat that gives the eye a reason to look down before the options land. The buttons then rise a short distance and fade, staggered, and nothing scales: every one of them is a labelled row, and text that overshoots its size is the cheapest-looking thing in interface motion. The glyphs shipped here are deliberately neutral placeholder shapes, so the pattern is about the motion and not about anyone's mark.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
A short field, a labelled separator, then a stack of provider rows.
Related patterns
- Permission Scope ListA consent screen reveals each requested scope in turn and keeps the approve button inert until they have all landed.
- OTP Code EntrySix boxes: the waiting box lifts, digits settle in, and a wrong code answers with one short nudge.
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.