Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/card-elevation-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@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.

The `cardBox` element exposes a `data-elevation="flush"` attribute when flush is active, giving className-based themes a hook to neutralize their card chrome via attribute selectors. The `shadcn` theme uses this hook to drop its `shadow-sm border` utilities under flush.
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 };
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ describe('AppearanceProvider options flows', () => {
showOptionalFields: false,
socialButtonsPlacement: 'bottom',
socialButtonsVariant: 'iconButton',
elevation: 'flush',
},
}}
>
Expand All @@ -255,6 +256,7 @@ describe('AppearanceProvider options flows', () => {
expect(result.current.parsedOptions.showOptionalFields).toBe(false);
expect(result.current.parsedOptions.socialButtonsPlacement).toBe('bottom');
expect(result.current.parsedOptions.socialButtonsVariant).toBe('iconButton');
expect(result.current.parsedOptions.elevation).toBe('flush');
});

it('sets the parsedOptions correctly from the appearance prop', () => {
Expand All @@ -272,6 +274,7 @@ describe('AppearanceProvider options flows', () => {
showOptionalFields: true,
socialButtonsPlacement: 'top',
socialButtonsVariant: 'blockButton',
elevation: 'flush',
},
}}
>
Expand All @@ -289,6 +292,7 @@ describe('AppearanceProvider options flows', () => {
expect(result.current.parsedOptions.showOptionalFields).toBe(true);
expect(result.current.parsedOptions.socialButtonsPlacement).toBe('top');
expect(result.current.parsedOptions.socialButtonsVariant).toBe('blockButton');
expect(result.current.parsedOptions.elevation).toBe('flush');
});

it('sets the parsedOptions correctly from the globalAppearance and appearance prop', () => {
Expand All @@ -306,6 +310,7 @@ describe('AppearanceProvider options flows', () => {
showOptionalFields: false,
socialButtonsPlacement: 'bottom',
socialButtonsVariant: 'iconButton',
elevation: 'flush',
},
}}
appearance={{
Expand All @@ -319,6 +324,7 @@ describe('AppearanceProvider options flows', () => {
showOptionalFields: true,
socialButtonsPlacement: 'top',
socialButtonsVariant: 'blockButton',
elevation: 'raised',
},
}}
>
Expand All @@ -336,6 +342,7 @@ describe('AppearanceProvider options flows', () => {
expect(result.current.parsedOptions.showOptionalFields).toBe(true);
expect(result.current.parsedOptions.socialButtonsPlacement).toBe('top');
expect(result.current.parsedOptions.socialButtonsVariant).toBe('blockButton');
expect(result.current.parsedOptions.elevation).toBe('raised');
});

it('removes the base theme when simpleStyles is passed to globalAppearance', () => {
Expand Down
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 @@ -52,6 +52,7 @@ const defaultOptions: ParsedOptions = {
animations: true,
unsafe_disableDevelopmentModeWarnings: false,
autoFocus: true,
elevation: 'raised',
};

const defaultCaptchaOptions: ParsedCaptcha = {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/elements/Card/CardClerkAndPagesTag.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';

import { useEnvironment } from '../../contexts';
import { Box, Col, Flex, Icon, Link, Text } from '../../customizables';
import { Box, Col, descriptors, Flex, Icon, Link, Text } from '../../customizables';
import { useDevMode } from '../../hooks/useDevMode';
import { LogoMark } from '../../icons';
import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
Expand All @@ -28,6 +28,7 @@ export const CardClerkAndPagesTag = React.memo(

return (
<Box
elementDescriptor={descriptors.footerItem}
sx={[
{
width: '100%',
Expand Down
124 changes: 93 additions & 31 deletions packages/ui/src/elements/Card/CardRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,102 @@
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 type { InternalTheme, PropsOfComponent } from '../../styledSystem';
import { mqu } from '../../styledSystem';
import { ApplicationLogo } from '../ApplicationLogo';
import { useFlowMetadata } from '../contexts';
import { ModalContext } from '../Modal';

type CardRootProps = PropsOfComponent<typeof Col>;
// Flush overrides injected into parsedElements after baseTheme but before user overrides.
const getFlushElements = (t: InternalTheme) => ({
cardBox: {
borderWidth: 0,
borderRadius: 0,
boxShadow: 'none',
overflow: 'visible',
},
card: {
borderWidth: 0,
borderRadius: 0,
boxShadow: 'none',
backgroundColor: 'transparent',
paddingInline: 0,
paddingBlock: 0,
marginBlockStart: 0,
marginInline: 0,
},
footer: {
background: 'transparent',
marginTop: t.space.$8,
paddingTop: 0,
rowGap: t.space.$8,
'>:first-of-type': {
padding: 0,
},
'>:not(:first-of-type)': {
borderTopWidth: 0,
padding: 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 flushElements = getFlushElements(appearance.parsedInternalTheme);
const newParsedElements = [appearance.parsedElements[0], flushElements, ...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}
data-elevation={isFlush ? 'flush' : undefined}
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 +109,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
)}
</>
);
});
Loading
Loading