Forms and inputsSocial sign-in
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callSocial sign-in
One-tap providers that remember the last one you used. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add social-sign-inReact
npx designme add social-sign-in --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Social sign-in is a sign-in card with one button per provider, such as Google, GitHub, Figma and SSO, plus a way to continue with email. The provider used last time gets an outline and a Last used badge, so a returning person can find it quickly. After sign-in the card swaps to a welcome message with the provider's name and a Sign out button.
- When people sign in to your agent product with an existing work account
- When returning users tend to forget which provider they used last time
- When sign-in calls an OAuth flow that takes a moment and the button should show it is working
- When you need a small signed-in confirmation before the product loads
- Providers show an official
brandmark, aniconsuch as a lock for SSO, or aletterstile. - The
lastUsedprovider gets an outline and a Last used badge, and a successful sign-in updates it. - If
onSignInreturns a promise, that provider's button spins until it settles and the other buttons wait. - Resolving to false or rejecting keeps the person signed out.
- Once signed in, the card shows an avatar, "Welcome back" with the
username, which provider was used and a Sign out button. title,logoandemailLabelchange the heading, the mark on the tile and the email link.
- The button that is signing in sets
aria-busywhile its promise is pending. - Every provider is a native button labelled "Continue with" and the provider's name.
Usage
Import it and drive it from your own state.
Import
import { SocialSignIn } from '@/components/designme/social-sign-in/SocialSignIn';Example
The demo above as a React component, 19 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { SocialSignIn, type SocialSignInProvider } from './SocialSignIn';
const PROVIDERS: SocialSignInProvider[] = [
{ key: 'google', label: 'Google', brand: 'google' }, { key: 'github', label: 'GitHub', brand: 'github' },
{ key: 'figma', label: 'Figma', brand: 'figma' }, { key: 'sso', label: 'SSO', icon: 'lock' }
];
/* ---------- Social sign-in: Figma was used last; any provider signs Maya in after a second ---------- */
export default function SocialSignInDemo() {
return (
<div style={{ width: '100%' }}>
<SocialSignIn providers={PROVIDERS} defaultLastUsed="figma" user={{ name: 'Maya', initials: 'MK', color: 'var(--dm-av-l)' }}
onSignIn={() => new Promise(res => setTimeout(res, 1100))} />
</div>
);
}
Props
Everything SocialSignIn takes.
| Prop | Type | What it does |
|---|---|---|
providers | SocialSignInProvider[] | Defaults to Google, GitHub, Figma and SSO |
lastUsed | string | null | The provider used last time, by key. It gets an outline and a "Last used" badge |
defaultLastUsed | string | null | |
onLastUsedChange | (key: string | null) => void | |
signedIn | string | null | The provider the person is signed in with, by key, or null. Signing in sets it, Sign out clears it |
defaultSignedIn | string | null | |
onSignedInChange | (key: string | null) => void | |
onSignIn | (provider: SocialSignInProvider) => unknown | Called when a provider is clicked. Return a promise and its button spins until it settles; resolve to false or reject to stay signed out |
onSignOut | () => void | |
onEmail | () => void | Adds a link under the providers |
user | SocialSignInUser | |
title | string | |
logo | ReactNode | Your logo, on the dark tile at the top. Defaults to the DesignMe mark |
emailLabel | string | |
className | string |
Code
The full source, as the CLI installs it.
SocialSignIn.tsxReact · 125 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Brand, type BrandName } from '../lib/brands';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Avatar, enter, Spin } from '../lib/motion';
import '../designme.css';
import './social-sign-in.css';
/** A way to sign in */
export interface SocialSignInProvider {
key: string;
/** Its name, after "Continue with", like "GitHub" */
label: string;
/** Its official mark: google, github, figma or slack */
brand?: BrandName;
/** An icon instead, like 'lock' for SSO */
icon?: IconName;
/** A letter tile instead, like "MS" */
letters?: string;
}
/** The person who signed in */
export interface SocialSignInUser {
/** Shown as "Welcome back, Maya" */
name: string;
initials: string;
/** The avatar colour, like 'var(--dm-av-l)' */
color?: string;
}
export interface SocialSignInProps {
/** Defaults to Google, GitHub, Figma and SSO */
providers?: SocialSignInProvider[];
/** The provider used last time, by key. It gets an outline and a "Last used" badge */
lastUsed?: string | null;
defaultLastUsed?: string | null;
onLastUsedChange?: (key: string | null) => void;
/** The provider the person is signed in with, by key, or null. Signing in sets it, Sign out clears it */
signedIn?: string | null;
defaultSignedIn?: string | null;
onSignedInChange?: (key: string | null) => void;
/** Called when a provider is clicked. Return a promise and its button spins until it settles; resolve to false or reject to stay signed out */
onSignIn?: (provider: SocialSignInProvider) => unknown;
onSignOut?: () => void;
/** Adds a link under the providers */
onEmail?: () => void;
user?: SocialSignInUser;
title?: string;
/** Your logo, on the dark tile at the top. Defaults to the DesignMe mark */
logo?: ReactNode;
emailLabel?: string;
className?: string;
}
const PROVIDERS: SocialSignInProvider[] = [
{ key: 'google', label: 'Google', brand: 'google' }, { key: 'github', label: 'GitHub', brand: 'github' },
{ key: 'figma', label: 'Figma', brand: 'figma' }, { key: 'sso', label: 'SSO', icon: 'lock' }
];
const Mark = ({ p }: { p: SocialSignInProvider }) =>
p.brand ? <Brand name={p.brand} /> : p.letters ? <span className="dm-lt">{p.letters}</span> : <Icon name={p.icon ?? 'lock'} />;
const Logo = () => (
<svg viewBox="-20 -83 799 799" aria-hidden="true" className="dm-logo"><path fill="currentColor" d="M149.497 283.784C149.477 284.047 149.474 284.311 149.487 284.575C156.385 340.034 183.548 391.116 225.9 428.196C267.989 465.044 322.188 485.532 378.414 485.855L379.743 485.859C436.453 485.868 491.188 465.353 533.619 428.212C575.912 391.194 603.062 340.216 610.018 284.856L610.018 281.512C610.008 277.337 608.324 273.315 605.303 270.335C602.277 267.351 598.155 265.656 593.837 265.656L165.647 265.656C161.338 265.656 157.226 267.349 154.21 270.331C151.198 273.309 149.524 277.326 149.523 281.491L149.523 283.437L149.497 283.784ZM114.378 247.395C118.396 247.391 122.254 245.919 125.214 243.29C128.063 240.759 129.887 237.33 130.396 233.629L130.396 233.35L130.437 232.917C136.355 171.634 165.162 114.754 211.202 73.3687C257.239 31.9859 317.206 9.06063 379.393 9.04448L379.396 9.04448C441.593 9.04583 501.574 31.964 547.624 73.3482C593.677 114.735 622.491 171.624 628.411 232.917L628.453 233.35L628.453 233.643C628.953 237.344 630.773 240.774 633.621 243.304C636.58 245.932 640.437 247.399 644.452 247.395L725.644 247.395C728.882 247.404 732.089 248.06 735.071 249.329C738.055 250.598 740.753 252.453 743.001 254.787C745.252 257.123 747.005 259.891 748.151 262.924C749.296 265.954 749.81 269.183 749.662 272.418L749.663 272.418C745.484 366.797 704.617 455.919 635.615 521.211C566.616 586.499 474.809 622.924 379.338 622.924C283.872 622.924 192.071 586.496 123.081 521.206C54.0935 455.917 13.2412 366.804 9.07133 272.438L9.07036 272.438L9.07036 272.436C9.07007 272.43 9.06967 272.423 9.06938 272.417L9.07036 272.417C8.92272 269.182 9.43655 265.953 10.5811 262.924C11.7276 259.89 13.481 257.123 15.7315 254.787C17.9805 252.452 20.6797 250.597 23.6622 249.329C26.6443 248.06 29.8504 247.404 33.0889 247.395L114.378 247.395Z" /></svg>
);
/** One-tap sign-in that remembers the provider you used last. */
export function SocialSignIn({ providers = PROVIDERS, lastUsed, defaultLastUsed = null, onLastUsedChange, signedIn, defaultSignedIn = null, onSignedInChange,
onSignIn, onSignOut, onEmail, user, title = 'Sign in to DesignMe', logo, emailLabel = 'Continue with email', className }: SocialSignInProps) {
const [last, setLast] = useControllable(lastUsed, defaultLastUsed, onLastUsedChange);
const [session, setSession] = useControllable(signedIn, defaultSignedIn, onSignedInChange);
const [busy, setBusy] = useState<string | null>(null);
// signing in swaps the providers for a shorter welcome: it keeps the height it had and centres the welcome in it
const [held, setHeld] = useState<number>();
const root = useRef<HTMLDivElement>(null);
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const signIn = (p: SocialSignInProvider) => {
if (busy) return;
const done = (ok: unknown) => { if (!alive.current) return; setBusy(null); if (ok === false) return; setHeld(root.current?.offsetHeight); setLast(p.key); setSession(p.key); };
const out = onSignIn?.(p);
if (out && typeof (out as Promise<unknown>).then === 'function') {
setBusy(p.key);
(out as Promise<unknown>).then(done, () => { if (alive.current) setBusy(null); });
} else done(out);
};
const signOut = () => { setHeld(undefined); setSession(null); onSignOut?.(); };
const who = providers.find(p => p.key === session);
const step = (i: number) => (user ? i : i - 1) * 60;
return (
<div data-dm="" ref={root} className={cx('dm-ssi dm-panel', session && held && 'dm-held', className)} style={session && held ? { minHeight: held } : undefined}>
{session ? (
<div className="dm-ssi-ok">
{/* the welcome comes in a line at a time */}
{user && <Avatar initials={user.initials} color={user.color ?? 'var(--dm-av-l)'} className="dm-enter" />}
<b {...enter(step(1))}>{user ? `Welcome back, ${user.name}` : 'You are signed in'}</b>
<span className="dm-muted dm-enter" style={enter(step(2)).style}>Signed in with {who?.label ?? session}</span>
<button type="button" className="dm-pill dm-ghost dm-sm dm-enter" style={enter(step(3)).style} onClick={signOut}>Sign out</button>
</div>
) : <>
<div className="dm-ssi-h"><span className="dm-ssi-lg">{logo ?? <Logo />}</span><b>{title}</b></div>
<div className={cx('dm-ssi-list', busy && 'dm-wait')}>
{providers.map((p, i) => {
const e = enter(i * 50);
return (
<button key={p.key} type="button" className={cx('dm-ssi-b', p.key === last && 'dm-last-used', busy === p.key && 'dm-busy', e.className)} style={e.style}
aria-busy={busy === p.key || undefined} onClick={() => signIn(p)}>
<span className="dm-mk">{busy === p.key ? <Spin /> : <Mark p={p} />}</span>
<span>Continue with {p.label}</span>
{p.key === last && <span className="dm-last">Last used</span>}
</button>
);
})}
</div>
<div className="dm-ssi-or">or</div>
<button type="button" className="dm-ssi-mail" onClick={onEmail}>{emailLabel}</button>
</>}
</div>
);
}
social-sign-in.cssReact · 26 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Social sign-in ---------- */
.dm-ssi{padding:28px 22px var(--dm-s-5);display:flex;flex-direction:column;align-items:center;gap:14px;max-width:320px;margin:0 auto;width:100%}
.dm-ssi-h{display:flex;flex-direction:column;align-items:center;gap:var(--dm-s-3);text-align:center}
.dm-ssi-lg{width:44px;height:44px;border-radius:14px;background:var(--dm-ink);color:var(--dm-on-ink);display:grid;place-items:center}
.dm-ssi-lg .dm-logo{width:26px;height:26px}
.dm-ssi-h b{font-weight:500;font-size:14px}
.dm-ssi-list{display:flex;flex-direction:column;gap:var(--dm-s-2);width:100%}
.dm-ssi-b{position:relative;display:flex;align-items:center;justify-content:center;gap:10px;height:42px;padding:0 18px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent);font-size:13.5px;font-weight:500;transition:background .2s,box-shadow .2s,transform .15s}
@media (hover:hover) and (pointer:fine){.dm-ssi-b:hover{background:var(--dm-wash);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}}
.dm-ssi-b:active{transform:scale(.98)}
.dm-ssi-b .dm-mk{width:18px;height:18px;display:grid;place-items:center;flex:none}
.dm-ssi-b .dm-mk svg,.dm-ssi-b .dm-mk img{width:18px;height:18px;display:block}
.dm-ssi-b .dm-mk .dm-i{width:16px;height:16px}
.dm-ssi-b .dm-mk .dm-lt{min-width:18px;height:18px;padding:0 3px;border-radius:5px;background:var(--dm-ink);color:var(--dm-on-ink);display:grid;place-items:center;font-size:10px;font-weight:500}
.dm-ssi-b .dm-last{position:absolute;top:-8px;right:22px;font-size:10.5px;font-weight:500;color:var(--dm-fg2);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent);padding:1px 7px;border-radius:var(--dm-r-pill)}
.dm-ssi-b.dm-last-used{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-on) 50%,transparent)}
.dm-ssi-b.dm-busy{pointer-events:none}
.dm-ssi-list.dm-wait .dm-ssi-b:not(.dm-busy){opacity:.45}
.dm-ssi-or{display:flex;align-items:center;gap:10px;width:100%;font-size:11.5px;color:var(--dm-muted)}
.dm-ssi-or::before,.dm-ssi-or::after{content:"";flex:1;height:1px;background:var(--dm-line)}
.dm-ssi-mail{font-size:12.5px;color:var(--dm-fg2);padding:var(--dm-s-1) 10px;border-radius:var(--dm-r-pill);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.dm-ssi-mail:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-ssi-ok{display:flex;flex-direction:column;align-items:center;gap:var(--dm-s-2);padding:var(--dm-s-2) 0 var(--dm-s-1);text-align:center;font-size:13px}
.dm-ssi-ok .dm-av{width:44px;height:44px;font-size:14px}
social-sign-in.htmlHTML · 11 lines
<div class="card" data-c="social-signin">
<div class="stage-in">
<div class="ssi panel">
<div class="ssi-h"><span class="ssi-lg"><svg viewBox="-20 -83 799 799" aria-hidden="true" class="logo"><path d="M149.497 283.784C149.477 284.047 149.474 284.311 149.487 284.575C156.385 340.034 183.548 391.116 225.9 428.196C267.989 465.044 322.188 485.532 378.414 485.855L379.743 485.859C436.453 485.868 491.188 465.353 533.619 428.212C575.912 391.194 603.062 340.216 610.018 284.856L610.018 281.512C610.008 277.337 608.324 273.315 605.303 270.335C602.277 267.351 598.155 265.656 593.837 265.656L165.647 265.656C161.338 265.656 157.226 267.349 154.21 270.331C151.198 273.309 149.524 277.326 149.523 281.491L149.523 283.437L149.497 283.784ZM114.378 247.395C118.396 247.391 122.254 245.919 125.214 243.29C128.063 240.759 129.887 237.33 130.396 233.629L130.396 233.35L130.437 232.917C136.355 171.634 165.162 114.754 211.202 73.3687C257.239 31.9859 317.206 9.06063 379.393 9.04448L379.396 9.04448C441.593 9.04583 501.574 31.964 547.624 73.3482C593.677 114.735 622.491 171.624 628.411 232.917L628.453 233.35L628.453 233.643C628.953 237.344 630.773 240.774 633.621 243.304C636.58 245.932 640.437 247.399 644.452 247.395L725.644 247.395C728.882 247.404 732.089 248.06 735.071 249.329C738.055 250.598 740.753 252.453 743.001 254.787C745.252 257.123 747.005 259.891 748.151 262.924C749.296 265.954 749.81 269.183 749.662 272.418L749.663 272.418C745.484 366.797 704.617 455.919 635.615 521.211C566.616 586.499 474.809 622.924 379.338 622.924C283.872 622.924 192.071 586.496 123.081 521.206C54.0935 455.917 13.2412 366.804 9.07133 272.438L9.07036 272.438L9.07036 272.436C9.07007 272.43 9.06967 272.423 9.06938 272.417L9.07036 272.417C8.92272 269.182 9.43655 265.953 10.5811 262.924C11.7276 259.89 13.481 257.123 15.7315 254.787C17.9805 252.452 20.6797 250.597 23.6622 249.329C26.6443 248.06 29.8504 247.404 33.0889 247.395L114.378 247.395Z" fill="currentColor" /></svg></span><b>Sign in to DesignMe</b></div>
<div class="ssi-list"></div>
<div class="ssi-or">or</div>
<button class="ssi-mail">Continue with email</button>
</div>
</div>
</div>
social-sign-in.cssHTML · 25 lines
/* ---------- Social sign-in ---------- */
.ssi{padding:28px 22px var(--s-5);display:flex;flex-direction:column;align-items:center;gap:14px;max-width:320px;margin:0 auto;width:100%}
.ssi-h{display:flex;flex-direction:column;align-items:center;gap:var(--s-3);text-align:center}
.ssi-lg{width:44px;height:44px;border-radius:14px;background:var(--ink);color:var(--on-ink);display:grid;place-items:center}
.ssi-lg .logo{width:26px;height:26px}
.ssi-h b{font-weight:500;font-size:14px}
.ssi-list{display:flex;flex-direction:column;gap:var(--s-2);width:100%}
.ssi-b{position:relative;display:flex;align-items:center;justify-content:center;gap:10px;height:42px;padding:0 18px;border-radius:var(--r-pill);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent);font-size:13.5px;font-weight:500;transition:background .2s,box-shadow .2s,transform .15s}
@media (hover:hover) and (pointer:fine){.ssi-b:hover{background:var(--wash);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}}
.ssi-b:active{transform:scale(.98)}
.ssi-b .mk{width:18px;height:18px;display:grid;place-items:center;flex:none}
.ssi-b .mk svg,.ssi-b .mk img{width:18px;height:18px;display:block}
.ssi-b .mk .i{width:16px;height:16px}
.ssi-b .mk .lt{min-width:18px;height:18px;padding:0 3px;border-radius:5px;background:var(--ink);color:var(--on-ink);display:grid;place-items:center;font-size:10px;font-weight:500}
.ssi-b .last{position:absolute;top:-8px;right:22px;font-size:10.5px;font-weight:500;color:var(--fg2);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent);padding:1px 7px;border-radius:var(--r-pill)}
.ssi-b.last-used{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-on) 50%,transparent)}
.ssi-b.busy{pointer-events:none}
.ssi-list.wait .ssi-b:not(.busy){opacity:.45}
.ssi-or{display:flex;align-items:center;gap:10px;width:100%;font-size:11.5px;color:var(--muted)}
.ssi-or::before,.ssi-or::after{content:"";flex:1;height:1px;background:var(--line)}
.ssi-mail{font-size:12.5px;color:var(--fg2);padding:var(--s-1) 10px;border-radius:var(--r-pill);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.ssi-mail:hover{color:var(--fg);background:var(--row)}}
.ssi-ok{display:flex;flex-direction:column;align-items:center;gap:var(--s-2);padding:var(--s-2) 0 var(--s-1);text-align:center;font-size:13px}
.ssi-ok .av{width:44px;height:44px;font-size:14px}
social-sign-in.jsHTML · 20 lines
DM.def('social-signin', (r, c) => {
const P = [['Google','google'],['GitHub','github'],['Figma','figma'],['SSO','']];
let last = 'Figma';
const list = $('.ssi-list', r), root = $('.ssi', r);
const mark = p => p[1] ? BRAND[p[1]] : I('lock','s');
function paint(){
list.innerHTML = P.map(p => `<button class="ssi-b${p[0] === last ? ' last-used' : ''}" data-p="${p[0]}"><span class="mk">${mark(p)}</span><span class="lb">Continue with ${p[0]}</span>${p[0] === last ? '<span class="last">Last used</span>' : ''}</button>`).join('') + '';
stagger($$('.ssi-b', list), 50);
$$('.ssi-b', list).forEach(b => b.onclick = () => c.run(async () => {
b.classList.add('busy'); list.classList.add('wait'); $('.mk', b).innerHTML = '<span class="spin"></span>';
await c.wait(1100); last = b.dataset.p;
hold(root); const kids = [...root.children]; kids.forEach(k => k.hidden = true);
const ok = html(`<div class="ssi-ok">${AV('MK','var(--av-l)')}<b>Welcome back, Maya</b><span class="muted">Signed in with ${last}</span><button class="pill ghost sm">Sign out</button></div>`);
root.appendChild(ok); stagger([...ok.children], 60);
$('button', ok).onclick = () => { ok.remove(); release(root); kids.forEach(k => k.hidden = false); list.classList.remove('wait'); paint(); };
}));
}
paint();
});
Questions
What people ask about it.
- How do I connect it to my OAuth flow?
- Handle
onSignIn, which receives the clicked provider, and return a promise. The button spins until it resolves. Resolve to false or reject to keep the card signed out. - How does it remember the last provider between visits?
- It doesn't store anything itself. Save the key from
onLastUsedChangewherever you like, then pass it back aslastUsedordefaultLastUsednext time. - Can I add a provider that has no brand mark, like Microsoft?
- Yes. Give it
letters, such as "MS", for a letter tile, or aniconname. Built-in brand marks cover google, github, figma and slack.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.