Skip to Content
SDKs@synqid/react

@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/react in there is the same code you would write from scratch reading this page.

Install

pnpm add @synqid/react @synqid/js

Provider

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 via requestSignIn(). Resolves with User on success, null on dismiss.
  • <Connect> — wallet-only. Opens via requestConnect(). The user is already signed in; this is for linking a wallet inline. Resolves with walletAddress on success, null on 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.

TierWhat it givesWhat it costs
Theme defaultsBrand colors, radius, spacing via tokens on <SynqProvider>. Modal renders Synq’s own polished default markup.Zero code. Use this 90% of the time.
Compound partsReplace 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 headlessRender-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.

GroupTokens
Colorsprimary, primaryForeground, primaryHover, surface, surfaceElevated, border, borderHover, textPrimary, textMuted, textOnPrimary, danger, success, warning, backdrop
TypographyfontFamily, fontFamilyDisplay, fontFamilyMono, fontWeightHeadline, fontWeightButton
SpacingspacingXs, spacingSm, spacingMd, spacingLg, spacingXl
RadiiradiusSm, radiusMd, radiusLg, radiusFull, radius (sugar — sets all radius*)
EffectsshadowSm, shadowMd, shadowLg, glow, backdropBlur
MotiondurationFast, 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 (via clsx).

  • 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 MyTailwindButton is the rendered element. Synq passes onClick, 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

PartPurposeNotable props
<SignIn.Backdrop>Click-to-dismiss backdropcloseOnClick
<SignIn.Content>Dialog containeras="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 srcsrc
<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 eachrecommended, hidden, direction
<SignIn.Provider id="google">Single provider button. Renders the provider’s logo + labelid, asChild
<SignIn.WalletButton>Opens the wallet flow inline (no redirect)asChild
<SignIn.EmailInput>Email + Send code buttonasChild
<SignIn.OtpInput>6-digit OTP entry after emaillength
<SignIn.Divider>Horizontal rule with optional label—
<SignIn.ErrorBanner>Renders signInError when presentformat
<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 | null

The 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-describedby set from <SignIn.Title> and <SignIn.Description>
  • role="dialog" + aria-modal="true"
  • Esc to close (toggleable via closeOnEscape)
  • 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:

AttributeValues
data-synqroot, backdrop, content, header, title, description, provider-list, provider, wallet, email, otp, divider, error, loading-overlay, terms, footer, close
data-synq-stateidle, loading, error, success
data-loadingpresent when that specific part is busy (provider button mid-redirect, wallet mid-sign)
data-providergoogle, apple, etc. on each provider button
data-recommendedpresent on providers in recommendedProviders
data-errorpresent 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.

Last updated on