Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/card-elevation-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/ui": minor
---

Add `elevation` appearance option with `'raised'` (default) and `'flush'` values. When set to `flush`, card-based components render without border, box-shadow, border-radius, outer padding, and footer background, allowing them to sit flat against their container. Applies to `<SignIn />`, `<SignUp />`, `<Waitlist />`, `<CreateOrganization />`, `<OrganizationList />`, `<OAuthConsent />`, `<UserVerification />`, and session task components. Profile and popover components always render as raised. Modal components always render as raised regardless of this setting.
52 changes: 50 additions & 2 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,26 @@ function otherOptions() {
localization: document.getElementById('localizationSelect') as HTMLSelectElement,
};

const elevationSelect = document.getElementById('elevationSelect') as HTMLSelectElement;
const devWarningsToggle = document.getElementById('devWarningsToggle') as HTMLInputElement;

Object.entries(otherOptionsInputs).forEach(([key, input]) => {
const savedValue = sessionStorage.getItem(key);
if (savedValue) {
input.value = savedValue;
}
});

const savedElevation = sessionStorage.getItem('elevation');
if (savedElevation) {
elevationSelect.value = savedElevation;
}

const savedDevWarnings = sessionStorage.getItem('devWarnings');
if (savedDevWarnings !== null) {
devWarningsToggle.checked = savedDevWarnings === 'on';
}

const updateOtherOptions = () => {
void Clerk.__internal_updateProps({
options: Object.fromEntries(
Expand All @@ -304,16 +317,40 @@ function otherOptions() {
});
};

const updateAppearanceOptions = () => {
sessionStorage.setItem('elevation', elevationSelect.value);
sessionStorage.setItem('devWarnings', devWarningsToggle.checked ? 'on' : 'off');
const currentAppearance = Clerk.__internal_getOption('appearance') ?? {};
void Clerk.__internal_updateProps({
appearance: {
...currentAppearance,
options: {
...(currentAppearance as any).options,
elevation: elevationSelect.value as 'raised' | 'flush',
unsafe_disableDevelopmentModeWarnings: !devWarningsToggle.checked,
},
},
});
};

Object.values(otherOptionsInputs).forEach(input => {
input.addEventListener('change', updateOtherOptions);
});

elevationSelect.addEventListener('change', updateAppearanceOptions);
devWarningsToggle.addEventListener('change', updateAppearanceOptions);

resetOtherOptionsBtn?.addEventListener('click', () => {
otherOptionsInputs.localization.value = 'enUS';
elevationSelect.value = 'raised';
devWarningsToggle.checked = true;
sessionStorage.removeItem('elevation');
sessionStorage.removeItem('devWarnings');
updateOtherOptions();
updateAppearanceOptions();
});

return { updateOtherOptions };
return { updateOtherOptions, updateAppearanceOptions };
}

const themes: Record<string, unknown> = {
Expand Down Expand Up @@ -411,7 +448,7 @@ void (async () => {
const { updateVariables } = appearanceVariableOptions();
const { updateTheme } = themeSelector();
const { updatePreset } = presetSelector();
const { updateOtherOptions } = otherOptions();
const { updateOtherOptions, updateAppearanceOptions } = otherOptions();

const sidebars = document.querySelectorAll('[data-sidebar]');
document.addEventListener('keydown', e => {
Expand Down Expand Up @@ -540,6 +577,15 @@ void (async () => {
const initialTheme = initialThemeName ? themes[initialThemeName] : undefined;
const initialPresetName = sessionStorage.getItem('preset') ?? '';
const initialPreset = initialPresetName ? presets[initialPresetName] : undefined;
const initialElevation = sessionStorage.getItem('elevation') as 'raised' | 'flush' | null;
const initialDevWarnings = sessionStorage.getItem('devWarnings');
const initialAppearanceOptions: Record<string, unknown> = {};
if (initialElevation) {
initialAppearanceOptions.elevation = initialElevation;
}
if (initialDevWarnings === 'off') {
initialAppearanceOptions.unsafe_disableDevelopmentModeWarnings = true;
}

await Clerk.load({
...(componentControls.clerk.getProps() ?? {}),
Expand All @@ -549,6 +595,7 @@ void (async () => {
appearance: {
...(initialTheme ? { theme: initialTheme } : {}),
...presetToAppearance(initialPreset),
...(Object.keys(initialAppearanceOptions).length ? { options: initialAppearanceOptions } : {}),
},
});
renderCurrentRoute();
Expand All @@ -560,6 +607,7 @@ void (async () => {
updateVariables();
}
updateOtherOptions();
updateAppearanceOptions();
} else {
console.error(`Unknown route: "${route}".`);
}
Expand Down
20 changes: 19 additions & 1 deletion packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
}
</script>
</head>
<body class="flex min-h-full flex-col overflow-x-hidden bg-gray-50 lg:has-[*[data-sidebar]:not(.hidden)]:px-72">
<body class="flex min-h-full flex-col overflow-x-hidden bg-white lg:has-[*[data-sidebar]:not(.hidden)]:px-72">
<div
data-sidebar
class="fixed inset-y-0 left-0 w-72 overflow-y-auto border-r border-gray-100 bg-white px-2 py-4 max-lg:hidden"
Expand Down Expand Up @@ -436,6 +436,24 @@
<span class="font-mono text-xs">localization</span>
<select id="localizationSelect"></select>
</label>
<label class="flex items-center justify-between border-t border-gray-100 py-2">
<span class="font-mono text-xs">elevation</span>
<select
id="elevationSelect"
class="text-sm"
>
<option value="raised">raised</option>
<option value="flush">flush</option>
</select>
</label>
<label class="flex items-center justify-between border-t border-gray-100 py-2">
<span class="font-mono text-xs">devWarnings</span>
<input
type="checkbox"
id="devWarningsToggle"
checked
/>
</label>
</fieldset>
</div>

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/customizables/AppearanceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ const AppearanceProvider = (props: AppearanceProviderProps) => {
return <AppearanceContext.Provider value={ctxValue}>{props.children}</AppearanceContext.Provider>;
};

export { AppearanceProvider, useAppearance };
export { AppearanceContext, AppearanceProvider, useAppearance };
2 changes: 1 addition & 1 deletion packages/ui/src/customizables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { makeResponsive } from './makeResponsive';
import { sanitizeDomProps } from './sanitizeDomProps';

export * from './Flow';
export { AppearanceProvider, useAppearance } from './AppearanceContext';
export { AppearanceContext, AppearanceProvider, useAppearance } from './AppearanceContext';
export { descriptors } from './elementDescriptors';
export { localizationKeys, useLocalizations } from '../localization';
export type { LocalizationKey } from '../localization';
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/customizables/parseAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const defaultOptions: ParsedOptions = {
shimmer: true,
animations: true,
unsafe_disableDevelopmentModeWarnings: false,
elevation: 'raised',
};

const defaultCaptchaOptions: ParsedCaptcha = {
Expand Down
120 changes: 90 additions & 30 deletions packages/ui/src/elements/Card/CardRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,100 @@
import React from 'react';

import { Col, descriptors, generateFlowPartClassname, useAppearance } from '../../customizables';
import { AppearanceContext, Col, descriptors, generateFlowPartClassname, useAppearance } from '../../customizables';
import type { ElementDescriptor } from '../../customizables/elementDescriptors';
import type { PropsOfComponent } from '../../styledSystem';
import { mqu } from '../../styledSystem';
import { ApplicationLogo } from '../ApplicationLogo';
import { useFlowMetadata } from '../contexts';
import { ModalContext } from '../Modal';

type CardRootProps = PropsOfComponent<typeof Col>;
// Element style overrides for flush elevation. Injected into parsedElements at
// index 1 (after baseTheme, before user overrides) via AppearanceContext so they
// participate in the makeCustomizable cascade and can still be overridden by users.
const FLUSH_ELEMENTS = {
cardBox: {
borderWidth: 0,
borderRadius: 0,
boxShadow: 'none',
overflow: 'visible',
},
card: {
borderWidth: 0,
borderRadius: 0,
boxShadow: 'none',
backgroundColor: 'transparent',
padding: 0,
marginBlockStart: 0,
marginInline: 0,
},
footer: {
background: 'transparent',
marginTop: 0,
paddingTop: 0,
'>:first-of-type': {
paddingInline: 0,
},
'>:not(:first-of-type)': {
borderTopWidth: 0,
paddingInline: 0,
},
},
};

type CardRootProps = PropsOfComponent<typeof Col> & {
/**
* Override the visual elevation for this card instance.
* When omitted, falls back to `appearance.options.elevation` for page-mounted
* components, and `'raised'` for modals.
* Profile and popover card roots pass `'raised'` explicitly to opt out of flush.
*/
elevation?: 'raised' | 'flush';
};
export const CardRoot = React.forwardRef<HTMLDivElement, CardRootProps>((props, ref) => {
const { sx, children, ...rest } = props;
const { sx, children, elevation: elevationProp, ...rest } = props;
const appearance = useAppearance();
const flowMetadata = useFlowMetadata();

const rawModalCtx = React.useContext(ModalContext);
const isModal = rawModalCtx !== undefined;
// Explicit prop wins; modals always raised; otherwise use appearance option
const elevation = elevationProp ?? (isModal ? 'raised' : appearance.parsedOptions.elevation);
const isFlush = elevation === 'flush';

const augmentedAppearance = React.useMemo(() => {
if (!isFlush) {
return appearance;
}
const newParsedElements = [appearance.parsedElements[0], FLUSH_ELEMENTS, ...appearance.parsedElements.slice(1)];
return { ...appearance, parsedElements: newParsedElements };
}, [appearance, isFlush]);

const cardBox = (
<Col
elementDescriptor={[descriptors.cardBox, props.elementDescriptor as ElementDescriptor]}
className={generateFlowPartClassname(flowMetadata)}
ref={ref}
sx={[
t => ({
isolation: 'isolate',
maxWidth: `calc(100vw - ${t.sizes.$10})`,
width: t.sizes.$100,
borderWidth: t.borderWidths.$normal,
borderStyle: t.borderStyles.$solid,
borderColor: t.colors.$borderAlpha150,
borderRadius: t.radii.$xl,
color: t.colors.$colorForeground,
position: 'relative',
overflow: 'hidden',
}),
sx,
]}
{...rest}
>
{children}
</Col>
);

return (
<>
{appearance.parsedOptions.logoPlacement === 'outside' && (
Expand All @@ -25,33 +107,11 @@ export const CardRoot = React.forwardRef<HTMLDivElement, CardRootProps>((props,
})}
/>
)}
<Col
elementDescriptor={[descriptors.cardBox, props.elementDescriptor as ElementDescriptor]}
className={generateFlowPartClassname(flowMetadata)}
ref={ref}
sx={[
t => ({
/**
* All components should create their own stack context
* https://developer.mozilla.org/en-US/docs/Web/CSS/isolation
*/
isolation: 'isolate',
maxWidth: `calc(100vw - ${t.sizes.$10})`,
width: t.sizes.$100,
borderWidth: t.borderWidths.$normal,
borderStyle: t.borderStyles.$solid,
borderColor: t.colors.$borderAlpha150,
borderRadius: t.radii.$xl,
color: t.colors.$colorForeground,
position: 'relative',
overflow: 'hidden',
}),
sx,
]}
{...rest}
>
{children}
</Col>
{isFlush ? (
<AppearanceContext.Provider value={{ value: augmentedAppearance }}>{cardBox}</AppearanceContext.Provider>
) : (
cardBox
)}
</>
);
});
2 changes: 2 additions & 0 deletions packages/ui/src/elements/PopoverCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const PopoverCardRoot = React.forwardRef<
elementDescriptor={[descriptors.popoverBox, elementDescriptor as ElementDescriptor]}
{...rest}
ref={ref}
// Popover cards always render as raised — flush is scoped to simple card components
elevation='raised'
sx={[
t => ({
width: t.sizes.$94,
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const ProfileCardRoot = React.forwardRef<HTMLDivElement, PropsOfComponent
return (
<Card.Root
ref={ref}
// Profile cards always render as raised — flush is scoped to simple card components
elevation='raised'
sx={[
t => ({
width: t.sizes.$220,
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/internal/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,26 @@ export type Options = {
* @default false
*/
unsafe_disableDevelopmentModeWarnings?: boolean;

/**
* Controls the visual elevation of card-based components.
*
* - `'raised'` (default) — the card renders with its border, box-shadow, border-radius, and padding.
* - `'flush'` — removes the card border, box-shadow, border-radius, outer padding, and footer
* background so the component sits flat against its container.
*
* Applies to all card-based components including `<SignIn />`, `<SignUp />`,
* `<Waitlist />`, `<CreateOrganization />`, `<OrganizationList />`,
* `<OAuthConsent />`, `<UserVerification />`, and session task components.
*
* Does **not** affect profile components (`<UserProfile />`, `<OrganizationProfile />`)
* or popover components (`<UserButton />`, `<OrganizationSwitcher />`), which always render as raised.
*
* When a component is opened as a modal, it always renders as raised regardless of this setting.
*
* @default 'raised'
*/
elevation?: 'raised' | 'flush';
};

export type CaptchaAppearanceOptions = {
Expand Down
Loading