Components
ElvixSignOutButton

ElvixSignOutButton

Symmetric counterpart of `<ElvixSignInButton>`. One click invalidates the elvix session server-side, clears the SDK's local token, drops the `elvix_token` cookie, then navigates.

neutral · outline · center
brand · filled · center
destructive · filled · center
brand · #2D5A3D · left · 15px · radius 4 · w-full
components/account-menu.tsx
"use client";
import { ElvixSignOutButton } from "@elvix.is/sdk/react";
export function AccountMenu() {
return (
<ElvixSignOutButton
tone="destructive"
variant="filled"
size="md"
redirectAfterSignOut="/"
onResult={(r) => {
if (!r.ok) console.warn(r.error, r.message);
// The button has already cleared the SDK's in-memory + cookie
// token by the time onResult fires; the navigation also runs
// (unless you set redirectAfterSignOut={null}).
}}
/>
);
}

Props

PropTypeRequiredDefaultDescription
as"button" | "menuitem" | "link"optional"button"The form the action takes. button is the standalone CTA, menuitem a full-width dropdown / account-menu row, link an inline text link. Same sign-out behaviour, different placement. <ElvixSignOutMenuItem> and <ElvixSignOutLink> are thin aliases for the last two.
children(api: { signOut, busy }) => ReactNodeoptionalnoneHeadless escape hatch. Pass a render-fn and YOU own the element (a Radix <DropdownMenuItem>, your own button, anything). When provided, as and all styling props are ignored.
tone"neutral" | "brand" | "destructive"optional"neutral"Affordance. neutral for low-key footer / menu items, brand for emphasised primary action, destructive (red) for security-leaning surfaces.
variant"filled" | "outline" | "ghost"optional"outline"Visual treatment within the chosen tone.
labelstringoptionalfrom presetButton text override.
preset"sign-out" | "log-out" | "sign-out-of-elvix" | "sign-out-of-app"optional"sign-out"Canned verb. SaaS defaults to "Sign out"; banks / B2B legacy prefer "Log out".
type"standard" | "icon"optional"standard"Icon-only or icon + label.
shape"rectangle" | "pill" | "square" | "circle"optional"rectangle"Corner radius. Icon-only buttons coerce pill -> circle, rectangle -> square.
size"sm" | "md" | "lg"optional"md"Matches <ElvixSignInButton> sizing.
theme"auto" | "light" | "dark"optional"auto"Force a chord. auto inherits from the surrounding theme.
showIconbooleanoptionaltrueShow the leading icon. Set false for a label-only button.
icon(size: number) => ReactNodeoptionallucide LogOutOverride the default sign-out icon. Receives pixel size, returns a node.
brandColorstringoptionalelvix lavender (#6c5ce7)Overrides the brand chord on tone="brand" + variant="filled". Use the host's own brand.
onBrandColorstringoptional"#ffffff"Foreground (icon + label) painted on top of brandColor. Pick a WCAG-AA contrast pair.
align"left" | "center" | "right"optional"center"Content alignment inside the button. Combine with className="w-full" for a full-width menu item.
fontSizenumber | stringoptionalfrom size presetOverride the label font size. Number is treated as px; string is any CSS length.
borderRadiusnumber | stringoptionalfrom shape presetCustom corner radius. Wins over shape. Number is px; string is any CSS length ("8px", "50%").
redirectAfterSignOutstring | nulloptional"/"Where to navigate after sign-out. Pass null to let the host own navigation via onResult.
cookieNamestring | nulloptional"elvix_token"Client-side cookie to clear after the server invalidates the session. Set to null to skip (your server clears its own httpOnly cookie).
onResult(r: ElvixSignOutResult) => voidoptionalnoneTerminal callback. Fires AFTER the session is invalidated server-side AND the local token + cookie are cleared. ResponseDto-shape.

One action, four forms

The same sign-out logic ships in whatever shape your surface needs. Switch with as, or reach for a named alias, or go fully headless with a render-fn:

components/sign-out-forms.tsx
"use client";
import {
ElvixSignOutButton,
ElvixSignOutMenuItem,
ElvixSignOutLink,
} from "@elvix.is/sdk/react";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
export function SignOutForms() {
return (
<>
{/* 1. standalone button (default) */}
<ElvixSignOutButton tone="destructive" />
{/* 2. account-menu row, via prop OR the named alias */}
<ElvixSignOutButton as="menuitem" align="left" className="w-full" />
<ElvixSignOutMenuItem align="left" className="w-full" />
{/* 3. inline text link */}
<ElvixSignOutLink label="Log out" />
{/* 4. fully headless — wire onto your own design-system element */}
<ElvixSignOutButton>
{({ signOut, busy }) => (
<DropdownMenuItem onSelect={signOut} disabled={busy}>
{busy ? "Signing out…" : "Sign out"}
</DropdownMenuItem>
)}
</ElvixSignOutButton>
</>
);
}

<ElvixSignOutMenuItem> (= as="menuitem") and <ElvixSignOutLink> (= as="link") are exported alongside <ElvixSignOutButton> for hosts that prefer an explicit name over a prop. All three, plus the headless render-fn and the useSignOut / signOut entry points below, share one implementation — pick whichever fits the element you already have.

httpOnly-cookie hosts: clearing your own cookie is NOT a sign-out. If your server keeps the elvix token in its own httpOnly cookie (so the SDK can't read it client-side), deleting that cookie leaves the elvix session alive. On your sign-in page, Google One Tap auto-select (or redirect-if-authenticated) then silently re-signs the user in and bounces them straight back to the dashboard. Do all three: (1) call the SDK signOut() on the CLIENT so it sets the one-shot 'signed out' flag the sign-in form reads to suppress that auto-resume — pass redirectAfterSignOut: null so it does NOT navigate (the SDK can't revoke here anyway, since it can't read your httpOnly token; without null its default '/' redirect tears down the page and races your server action so the revoke below never runs); (2) end the elvix session server-side from a route/server-action that CAN read the cookie, by POSTing Authorization: Bearer <token> to /api/auth/sign-out?surface=app, then redirect to your sign-in route; (3) clear your own cookie. Step 3 alone is the recurring bug; the redirectAfterSignOut: null in step 1 is the second.

Three tones cover the common placements. neutral (default) reads as a low-pressure account-menu item. brand uses your app's brand chord for an emphasised primary action. destructive is solid red, the affordance pattern you want when sign-out is part of a security surface ("Sign out everywhere", "End this session"). The host picks the tone per placement; the surface stays consistent across the app.

The default leading icon is lucide's LogOut (door + arrow), the universal sign-out glyph. Hide it with showIcon={false} for label-only, or pass a custom node via icon={(px) => <YourIcon size={px} />}.

Cross-origin and same-origin behave identically. The SDK's cross-origin interceptor attaches Authorization: Bearer <token> and rewrites the URL to https://elvix.is/api/auth/sign-out when the host is on a customer origin. Same-origin (running on elvix.is itself) sends the session cookie. Either way, the backend emits the user.signed_out webhook for your application.

Need a host-controlled flow (e.g. you set your own httpOnly cookie server-side and want to tear it down through your route)? Set redirectAfterSignOut={null} and handle navigation inside onResult. The component still invalidates the elvix session and clears the SDK's local token; the cookie clear is gated by cookieName.

This button handles USER-INITIATED sign-out. Forced sign-outs (ban / pause / delete from the Console) flow through `ElvixLifecycleWatcher`, which clears the same state within ~9 seconds without any user interaction. Mount the watcher once on any signed-in surface; the button covers the click path.

Loading state. As soon as the user clicks, the icon swaps to a spinning Loader2 and the label switches to Signing out… until the server invalidates the session and the SDK clears its local state. aria-busy flips on the button so screen readers track the transition. The chord stays the same (tone / variant / brandColor unchanged) so the button doesn't shift.

First-click reliability. Internally the post-sign-out navigation now goes through window.location.replace(new URL(target, location.origin).toString()) — synchronous, with the host's own origin as the resolution base. The previous microtask wrap was occasionally letting a parallel host re-render swallow the redirect (the historical "have to click twice" report); this path is single-attempt and host-origin-anchored.

Use your own button: useSignOut hook

Already have a sign-out affordance in your design system? Skip the chrome and call the hook directly. Returns { run, busy }; run() resolves with the same SignOutResult shape, and busy is the in-flight state for spinner / disabled UI.

components/account-menu-item.tsx
"use client";
import { useSignOut } from "@elvix.is/sdk/react";
export function AccountMenuItem() {
const { run, busy } = useSignOut({ redirectAfterSignOut: "/" });
return (
<button type="button" disabled={busy} onClick={() => run()}>
{busy ? "Signing out…" : "Sign out"}
</button>
);
}

Use from outside React: signOut() function

Vanilla function for non-React contexts: keyboard shortcuts, idle-timeout handlers, route loaders, anywhere a hook can't be called. Same options object, same return type, no React imports.

lib/keyboard-shortcuts.ts
import { signOut } from "@elvix.is/sdk/react";
// From a keyboard shortcut, an idle-timeout handler, anywhere outside
// React. No hook needed.
window.addEventListener("keydown", async (e) => {
if (e.ctrlKey && e.shiftKey && e.key === "Q") {
const r = await signOut({ redirectAfterSignOut: "/" });
if (!r.ok) console.warn(r.error, r.message);
}
});

All three entry points share one implementation. Whichever you reach for, the flow is identical: POST /api/auth/sign-out?surface=app (cookie OR bearer, automatically), clear the SDK token, clear the cookie, navigate. Pick the one that fits the surface you have.

Related