@synqid/react
Wraps @synqid/js; the primitives are identical, plus a provider +
hooks and the composable headless modals documented below.
AI agent? Fetch
/llms.txt— single file, every SDK install command, every drop-in snippet. The 30-line section for@synqid/reactin there is the same code you would write from scratch reading this page.
Install
pnpm add @synqid/react @synqid/jsProvider
Wrap your tree once at the root:
import { SynqProvider } from '@synqid/react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<SynqProvider
issuer="https://synq.id"
clientId={process.env.NEXT_PUBLIC_SYNQ_CLIENT_ID!}
redirectUri="/api/oauth/callback"
>
{children}
</SynqProvider>
)
}Hooks
import { useUser, useSynq, useAccessToken } from '@synqid/react'
function Header() {
const user = useUser()
const { signIn, signOut, requestSignIn } = useSynq()
if (!user) return <button onClick={() => requestSignIn()}>Sign in</button>
return (
<>
<span>{user.firstName}</span>
<button onClick={signOut}>Sign out</button>
</>
)
}useUser() returns User | null. useAccessToken() returns the
current access token, auto-refreshing on expiry. useSynq() returns
the full client surface.
Quick components
For the common cases:
import { SignedIn, SignedOut, SignInButton, UserButton } from '@synqid/react'
<SignedOut>
<SignInButton provider="google" />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn><SignInButton> opens the headless SignIn modal if no provider is
passed, or redirects straight to the provider if one is.
Next.js App Router
Server side:
// app/api/auth/[...synq]/route.ts
import { synqHandlers } from '@synqid/react/server'
export const { GET, POST } = synqHandlers()import { auth } from '@synqid/react/server'
export default async function Page() {
const session = await auth()
if (!session) return <a href="/api/auth/signin">Sign in</a>
return <div>Hi {session.user.firstName}</div>
}Errors
const { signInError } = useSynq()
if (signInError?.code === 'access_denied') {
// user denied consent
}All signInError instances are SynqAuthError with .code,
.message, and .cause. Codes match the
OIDC error catalog.
Headless modals
@synqid/react/ui ships two modals — <SignIn> and <Connect> — both
fully composable, fully unstyled, and themed by your brand. The
auth provider owns the open/close state; you render the modal once
near your root and trigger it from anywhere with useSynq().
The two modals are deliberately distinct:
<SignIn>— full sign-in surface. Opens viarequestSignIn(). Resolves withUseron success,nullon dismiss.<Connect>— wallet-only. Opens viarequestConnect(). The user is already signed in; this is for linking a wallet inline. Resolves withwalletAddresson success,nullon dismiss.
Mount them once and they handle every future requestSignIn() /
requestConnect() call:
import { SignIn, Connect } from '@synqid/react/ui'
<SynqProvider {...config}>
<YourApp />
<SignIn />
<Connect />
</SynqProvider>That’s enough for the modals to work. Everything below is how to customize them.
Three customization tiers
Pick the lightest tier that gets you what you want.
| Tier | What it gives | What it costs |
|---|---|---|
| Theme defaults | Brand colors, radius, spacing via tokens on <SynqProvider>. Modal renders Synq’s own polished default markup. | Zero code. Use this 90% of the time. |
| Compound parts | Replace any part of the modal (header, provider list, individual provider button, divider, email block, footer) by passing children. Synq’s default markup falls away where you opt in. | Some JSX. Useful when you want bespoke layout but Synq’s behaviors. |
| Fully headless | Render-prop API: Synq hands you { providers, signIn, isLoading, error, ... } and you render everything. | All the JSX, all the a11y. Use when none of the above fits. |
Tier 1: theme defaults
Three ways to set a theme, lightest first:
1. Pick a preset by name. Built-in: dark | light | glass |
monochrome | brutalist.
<SynqProvider {...config} theme="glass" />2. Override individual tokens. Tokens are deep-merged on top of
the active preset (dark by default).
<SynqProvider
{...config}
theme={{
preset: 'light', // base preset (default: 'dark')
primary: '#FF7B56', // brand accent
primaryHover: '#E85A2D',
radius: '14px', // single-radius sugar overrides all radii
fontFamilyDisplay: 'Inter, system-ui',
}}
/>3. Split light + dark. Combined with colorScheme="system", the
modal switches automatically when the OS theme changes.
<SynqProvider
{...config}
colorScheme="system"
theme={{
light: { preset: 'light', primary: '#0A6E5E' },
dark: { preset: 'dark', primary: '#5FD8CA' },
}}
/>Tokens catalog
A rich set of named tokens covers every visual axis — defaults are
sensible so you only override what you care about. Every token is
emitted as a --synq-* CSS variable on the modal host so Tailwind
and global stylesheets can read them too.
| Group | Tokens |
|---|---|
| Colors | primary, primaryForeground, primaryHover, surface, surfaceElevated, border, borderHover, textPrimary, textMuted, textOnPrimary, danger, success, warning, backdrop |
| Typography | fontFamily, fontFamilyDisplay, fontFamilyMono, fontWeightHeadline, fontWeightButton |
| Spacing | spacingXs, spacingSm, spacingMd, spacingLg, spacingXl |
| Radii | radiusSm, radiusMd, radiusLg, radiusFull, radius (sugar — sets all radius*) |
| Effects | shadowSm, shadowMd, shadowLg, glow, backdropBlur |
| Motion | durationFast, durationBase, easingStandard |
CSS-variable bridge
CSS variables are exposed under --synq-* so Tailwind and global
stylesheets can read them:
.synq-modal {
--synq-primary: var(--brand-primary);
}The data-color-scheme attribute on the root reflects the active
scheme so you can target light vs dark in your own CSS:
.synq-modal[data-color-scheme="light"] [data-synq="content"] { … }Bring-your-own provider icons
Default Google/Apple/Microsoft/Discord/Facebook/X/Telegram/Matrica/ Solana icons ship inline (no extra dep). Override any of them with a React component, a URL, or an inline SVG string:
import { GoogleIcon } from '@/icons'
<SynqProvider
{...config}
providerIcons={{
google: <GoogleIcon />, // React node
apple: 'https://cdn.acme.com/apple-mark.svg', // URL → <img>
matrica: '<svg viewBox="0 0 24 24">…</svg>', // inline SVG
}}
/>Missing entries fall back to Synq’s built-in default, which falls back to a single-letter monogram if no default exists.
Slot-level className overrides
For Tailwind users who want sharper control without dropping to compound parts:
<SynqProvider
{...config}
signInClassNames={{
content: 'rounded-3xl bg-zinc-950/95 backdrop-blur-xl',
title: 'font-display text-2xl',
provider: 'rounded-xl ring-1 ring-zinc-800 hover:ring-zinc-600',
}}
/>Slots: backdrop, content, header, logo, title, description,
closeButton, providerList, provider, providerIcon,
providerLabel, emailInput, otpInput, walletButton, divider,
errorBanner, loadingOverlay, terms, footer. The
connectClassNames prop targets the <Connect> modal with its own
slot set.
Common per-modal tweaks without writing custom layout:
<SignIn
title="Sign in to Acme"
description="Use the same identity you use on the rest of Acme."
recommendedProviders={['google', 'apple']}
hiddenProviders={['facebook']}
showWallet={true}
showEmail={false}
termsUrl="https://acme.com/terms"
privacyUrl="https://acme.com/privacy"
closeOnBackdrop={true}
closeOnEscape={true}
/>recommendedProviders controls the order shown at the top.
hiddenProviders blacklist (but the brand’s providerOrder array on
the brand record is the deeper default).
Tier 2: compound parts
Want a custom layout but still want Synq to handle the provider list, loading, and error states? Use the compound API. Every part is optional; provide only what you want to override.
import { SignIn } from '@synqid/react/ui'
<SignIn>
{/* You provide structure. Synq fills semantics + behavior. */}
<SignIn.Backdrop />
<SignIn.Content>
<SignIn.Header>
<SignIn.Logo src="/brand-logo.svg" />
<SignIn.Title>Welcome to Acme</SignIn.Title>
<SignIn.Description>
Sign in or create an account. It is free.
</SignIn.Description>
<SignIn.CloseButton />
</SignIn.Header>
<SignIn.ErrorBanner /> {/* hidden when no error */}
<SignIn.ProviderList>
<SignIn.Provider id="google" />
<SignIn.Provider id="apple" />
</SignIn.ProviderList>
<SignIn.Divider>or</SignIn.Divider>
<SignIn.WalletButton />
<SignIn.Footer>
<SignIn.Terms />
</SignIn.Footer>
</SignIn.Content>
</SignIn>Every part is a real, styled element. You can:
-
Style any part by passing
className— the part’s default classes are merged with yours (viaclsx). -
Replace any part’s element entirely with
asChild(the Radix pattern). Synq forwards its behavior props to your child:<SignIn.Provider id="google" asChild> <MyTailwindButton size="lg"> Sign in with Google </MyTailwindButton> </SignIn.Provider>Now your
MyTailwindButtonis the rendered element. Synq passesonClick,disabled(during loading),data-loading,data-provider="google"so you can target it. -
Reorder freely. Put the divider above the wallet button. Drop the title. Make the close button a Discord emoji. None of it changes the auth behavior.
Parts catalog
| Part | Purpose | Notable props |
|---|---|---|
<SignIn.Backdrop> | Click-to-dismiss backdrop | closeOnClick |
<SignIn.Content> | Dialog container | as="div", focus trap baked in |
<SignIn.Header> | Top group; flex container by default | — |
<SignIn.Logo> | Renders brand logo from the OIDC discovery doc, falls back to src | src |
<SignIn.Title> | h2; ties to dialog aria-labelledby | — |
<SignIn.Description> | Subtitle; ties to aria-describedby | — |
<SignIn.CloseButton> | Triggers close. Auto-focus on open | — |
<SignIn.ProviderList> | Maps over enabled providers, rendering one button each | recommended, hidden, direction |
<SignIn.Provider id="google"> | Single provider button. Renders the provider’s logo + label | id, asChild |
<SignIn.WalletButton> | Opens the wallet flow inline (no redirect) | asChild |
<SignIn.EmailInput> | Email + Send code button | asChild |
<SignIn.OtpInput> | 6-digit OTP entry after email | length |
<SignIn.Divider> | Horizontal rule with optional label | — |
<SignIn.ErrorBanner> | Renders signInError when present | format |
<SignIn.LoadingOverlay> | Loading curtain during redirect | — |
<SignIn.Terms> | ”By signing in you agree…” line built from brand’s termsUrl and privacyUrl | — |
<SignIn.Footer> | Wrapper, semantic only | — |
The <Connect> modal has the same compound API minus
<Connect.ProviderList> (it is wallet-only):
<Connect>
<Connect.Backdrop />
<Connect.Content>
<Connect.Title>Connect a wallet</Connect.Title>
<Connect.WalletList /> {/* picker for installed wallets */}
<Connect.SigningPrompt /> {/* shows during signature wait */}
<Connect.ErrorBanner />
</Connect.Content>
</Connect>Tier 3: fully headless render-prop
When you want zero markup from Synq, render-prop the modal:
<SignIn>
{(ctx) => (
<MyDialog open={ctx.isOpen} onOpenChange={ctx.setOpen}>
<MyDialogTitle>Sign in</MyDialogTitle>
{ctx.error && <MyError>{ctx.error.message}</MyError>}
<div className="grid gap-2">
{ctx.providers.map((p) => (
<MyButton
key={p.id}
disabled={ctx.isLoading}
onClick={() => ctx.signIn(p.id)}
>
<img src={p.iconUrl} alt="" /> Continue with {p.name}
</MyButton>
))}
</div>
{ctx.walletAvailable && (
<MyButton onClick={ctx.signInWithWallet}>Use my wallet</MyButton>
)}
<MyEmailForm
onSubmit={(email) => ctx.requestEmailCode(email)}
/>
</MyDialog>
)}
</SignIn>ctx shape:
interface SignInRenderContext {
isOpen: boolean
setOpen: (open: boolean) => void
providers: Array<{
id: ProviderId // 'google' | 'apple' | ...
name: string // 'Google'
iconUrl: string // provider logo (themable)
recommended: boolean // appears in recommendedProviders order
}>
walletAvailable: boolean
emailEnabled: boolean
isLoading: boolean
loadingProvider: ProviderId | null
error: SynqAuthError | null
signIn: (providerId: ProviderId) => Promise<void>
signInWithWallet: () => Promise<void>
requestEmailCode: (email: string) => Promise<void>
submitEmailCode: (code: string) => Promise<void>
options: SignInModalOptions // the active call's options
close: () => void
}You own the markup. Synq owns the side effects.
<Connect> has the same render-prop shape minus providers and
emailEnabled, plus signMessage and walletAdapter.
Programmatic open
Open the modals anywhere with useSynq():
const { requestSignIn, requestConnect } = useSynq()
await requestSignIn({
title: 'Sign in to continue your checkout',
recommendedProviders: ['google'],
})
// resolves with User | null
const walletAddress = await requestConnect({
title: 'Connect a wallet to claim',
})
// resolves with string | nullThe promise rejects only on programmatic errors (misuse, network).
User dismissal resolves with null — sign-in failures resolve with
null too and surface the error on ctx.error for the next open.
Animations
The default surface animates with a 200ms fade + 12px rise on enter,
fade + 8px drop on exit. Override by passing motion={false} (no
animation) or motion={{ enter: '...', exit: '...' }} with framer-motion
variants.
For users with prefers-reduced-motion: reduce, all animation is
disabled regardless of props.
Accessibility
The default modals already cover:
- Focus trap with
react-focus-lock - Restore focus to opener on close
aria-labelledby+aria-describedbyset from<SignIn.Title>and<SignIn.Description>role="dialog"+aria-modal="true"Escto close (toggleable viacloseOnEscape)- Backdrop click to close (toggleable via
closeOnBackdrop) - Loading and error states announced via
aria-live="polite" - Provider buttons are
<button type="button">with accessible names
If you go fully headless, you are responsible for the same. The
render context gives you everything you need (error, isLoading,
isOpen), but the dialog semantics are now your job. Consider
Radix’s Dialog or React Aria’s Modal as the host.
Styling with Tailwind
The default markup uses data- attributes on every part so you can
write Tailwind selectors without classnames:
@layer components {
.synq-modal :where([data-synq="content"]) {
@apply rounded-2xl bg-zinc-900 p-6 shadow-2xl;
}
.synq-modal :where([data-synq="provider"][data-loading]) {
@apply opacity-60 cursor-not-allowed;
}
.synq-modal :where([data-synq="error"]) {
@apply text-red-400;
}
}State attributes set on parts:
| Attribute | Values |
|---|---|
data-synq | root, backdrop, content, header, title, description, provider-list, provider, wallet, email, otp, divider, error, loading-overlay, terms, footer, close |
data-synq-state | idle, loading, error, success |
data-loading | present when that specific part is busy (provider button mid-redirect, wallet mid-sign) |
data-provider | google, apple, etc. on each provider button |
data-recommended | present on providers in recommendedProviders |
data-error | present when an error is showing |
A complete custom example
import { SignIn } from '@synqid/react/ui'
import { motion } from 'motion/react'
<SignIn>
<SignIn.Backdrop className="bg-black/60 backdrop-blur-md" />
<SignIn.Content asChild>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="bg-zinc-950 border border-zinc-800 rounded-3xl p-8 w-full max-w-md"
>
<SignIn.Header className="mb-6">
<SignIn.Title className="font-display text-2xl text-white">
Welcome to Acme
</SignIn.Title>
<SignIn.Description className="text-zinc-400 mt-1">
Sign in or create an account.
</SignIn.Description>
</SignIn.Header>
<SignIn.ErrorBanner className="mb-4 rounded-lg bg-red-500/10 text-red-400 px-4 py-3 text-sm" />
<SignIn.ProviderList className="grid gap-2">
<SignIn.Provider
id="google"
className="flex items-center gap-3 rounded-lg bg-white text-black hover:bg-zinc-100 px-4 py-3 transition-colors disabled:opacity-50"
/>
<SignIn.Provider
id="apple"
className="flex items-center gap-3 rounded-lg bg-black text-white hover:bg-zinc-900 px-4 py-3 border border-zinc-800 transition-colors disabled:opacity-50"
/>
</SignIn.ProviderList>
<SignIn.Divider className="my-4 text-zinc-500 text-xs">
or with a wallet
</SignIn.Divider>
<SignIn.WalletButton className="w-full flex items-center justify-center gap-3 rounded-lg bg-purple-600 text-white hover:bg-purple-700 px-4 py-3 transition-colors" />
<SignIn.Footer className="mt-6 text-center text-xs text-zinc-500">
<SignIn.Terms />
</SignIn.Footer>
</motion.div>
</SignIn.Content>
</SignIn>This is roughly 30 lines and gives you a modal that looks completely custom, behaves correctly, and stays compatible across SDK upgrades because the parts are the contract, not the styling.