Skip to content

Commit 75e5d25

Browse files
feat(ui): Add flush appearance option (#8510)
1 parent 761ebdd commit 75e5d25

14 files changed

Lines changed: 412 additions & 38 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@clerk/ui": minor
3+
---
4+
5+
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.
6+
7+
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.

packages/clerk-js/sandbox/app.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,26 @@ function otherOptions() {
281281
localization: document.getElementById('localizationSelect') as HTMLSelectElement,
282282
};
283283

284+
const elevationSelect = document.getElementById('elevationSelect') as HTMLSelectElement;
285+
const devWarningsToggle = document.getElementById('devWarningsToggle') as HTMLInputElement;
286+
284287
Object.entries(otherOptionsInputs).forEach(([key, input]) => {
285288
const savedValue = sessionStorage.getItem(key);
286289
if (savedValue) {
287290
input.value = savedValue;
288291
}
289292
});
290293

294+
const savedElevation = sessionStorage.getItem('elevation');
295+
if (savedElevation) {
296+
elevationSelect.value = savedElevation;
297+
}
298+
299+
const savedDevWarnings = sessionStorage.getItem('devWarnings');
300+
if (savedDevWarnings !== null) {
301+
devWarningsToggle.checked = savedDevWarnings === 'on';
302+
}
303+
291304
const updateOtherOptions = () => {
292305
void Clerk.__internal_updateProps({
293306
options: Object.fromEntries(
@@ -304,16 +317,40 @@ function otherOptions() {
304317
});
305318
};
306319

320+
const updateAppearanceOptions = () => {
321+
sessionStorage.setItem('elevation', elevationSelect.value);
322+
sessionStorage.setItem('devWarnings', devWarningsToggle.checked ? 'on' : 'off');
323+
const currentAppearance = Clerk.__internal_getOption('appearance') ?? {};
324+
void Clerk.__internal_updateProps({
325+
appearance: {
326+
...currentAppearance,
327+
options: {
328+
...(currentAppearance as any).options,
329+
elevation: elevationSelect.value as 'raised' | 'flush',
330+
unsafe_disableDevelopmentModeWarnings: !devWarningsToggle.checked,
331+
},
332+
},
333+
});
334+
};
335+
307336
Object.values(otherOptionsInputs).forEach(input => {
308337
input.addEventListener('change', updateOtherOptions);
309338
});
310339

340+
elevationSelect.addEventListener('change', updateAppearanceOptions);
341+
devWarningsToggle.addEventListener('change', updateAppearanceOptions);
342+
311343
resetOtherOptionsBtn?.addEventListener('click', () => {
312344
otherOptionsInputs.localization.value = 'enUS';
345+
elevationSelect.value = 'raised';
346+
devWarningsToggle.checked = true;
347+
sessionStorage.removeItem('elevation');
348+
sessionStorage.removeItem('devWarnings');
313349
updateOtherOptions();
350+
updateAppearanceOptions();
314351
});
315352

316-
return { updateOtherOptions };
353+
return { updateOtherOptions, updateAppearanceOptions };
317354
}
318355

319356
const themes: Record<string, unknown> = {
@@ -411,7 +448,7 @@ void (async () => {
411448
const { updateVariables } = appearanceVariableOptions();
412449
const { updateTheme } = themeSelector();
413450
const { updatePreset } = presetSelector();
414-
const { updateOtherOptions } = otherOptions();
451+
const { updateOtherOptions, updateAppearanceOptions } = otherOptions();
415452

416453
const sidebars = document.querySelectorAll('[data-sidebar]');
417454
document.addEventListener('keydown', e => {
@@ -540,6 +577,15 @@ void (async () => {
540577
const initialTheme = initialThemeName ? themes[initialThemeName] : undefined;
541578
const initialPresetName = sessionStorage.getItem('preset') ?? '';
542579
const initialPreset = initialPresetName ? presets[initialPresetName] : undefined;
580+
const initialElevation = sessionStorage.getItem('elevation') as 'raised' | 'flush' | null;
581+
const initialDevWarnings = sessionStorage.getItem('devWarnings');
582+
const initialAppearanceOptions: Record<string, unknown> = {};
583+
if (initialElevation) {
584+
initialAppearanceOptions.elevation = initialElevation;
585+
}
586+
if (initialDevWarnings === 'off') {
587+
initialAppearanceOptions.unsafe_disableDevelopmentModeWarnings = true;
588+
}
543589

544590
await Clerk.load({
545591
...(componentControls.clerk.getProps() ?? {}),
@@ -549,6 +595,7 @@ void (async () => {
549595
appearance: {
550596
...(initialTheme ? { theme: initialTheme } : {}),
551597
...presetToAppearance(initialPreset),
598+
...(Object.keys(initialAppearanceOptions).length ? { options: initialAppearanceOptions } : {}),
552599
},
553600
});
554601
renderCurrentRoute();
@@ -560,6 +607,7 @@ void (async () => {
560607
updateVariables();
561608
}
562609
updateOtherOptions();
610+
updateAppearanceOptions();
563611
} else {
564612
console.error(`Unknown route: "${route}".`);
565613
}

packages/clerk-js/sandbox/template.html

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
}
3434
</script>
3535
</head>
36-
<body class="flex min-h-full flex-col overflow-x-hidden bg-gray-50 lg:has-[*[data-sidebar]:not(.hidden)]:px-72">
36+
<body class="flex min-h-full flex-col overflow-x-hidden bg-white lg:has-[*[data-sidebar]:not(.hidden)]:px-72">
3737
<div
3838
data-sidebar
3939
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"
@@ -436,6 +436,24 @@
436436
<span class="font-mono text-xs">localization</span>
437437
<select id="localizationSelect"></select>
438438
</label>
439+
<label class="flex items-center justify-between border-t border-gray-100 py-2">
440+
<span class="font-mono text-xs">elevation</span>
441+
<select
442+
id="elevationSelect"
443+
class="text-sm"
444+
>
445+
<option value="raised">raised</option>
446+
<option value="flush">flush</option>
447+
</select>
448+
</label>
449+
<label class="flex items-center justify-between border-t border-gray-100 py-2">
450+
<span class="font-mono text-xs">devWarnings</span>
451+
<input
452+
type="checkbox"
453+
id="devWarningsToggle"
454+
checked
455+
/>
456+
</label>
439457
</fieldset>
440458
</div>
441459

packages/ui/src/customizables/AppearanceContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ const AppearanceProvider = (props: AppearanceProviderProps) => {
2323
return <AppearanceContext.Provider value={ctxValue}>{props.children}</AppearanceContext.Provider>;
2424
};
2525

26-
export { AppearanceProvider, useAppearance };
26+
export { AppearanceContext, AppearanceProvider, useAppearance };

packages/ui/src/customizables/__tests__/parseAppearance.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ describe('AppearanceProvider options flows', () => {
238238
showOptionalFields: false,
239239
socialButtonsPlacement: 'bottom',
240240
socialButtonsVariant: 'iconButton',
241+
elevation: 'flush',
241242
},
242243
}}
243244
>
@@ -255,6 +256,7 @@ describe('AppearanceProvider options flows', () => {
255256
expect(result.current.parsedOptions.showOptionalFields).toBe(false);
256257
expect(result.current.parsedOptions.socialButtonsPlacement).toBe('bottom');
257258
expect(result.current.parsedOptions.socialButtonsVariant).toBe('iconButton');
259+
expect(result.current.parsedOptions.elevation).toBe('flush');
258260
});
259261

260262
it('sets the parsedOptions correctly from the appearance prop', () => {
@@ -272,6 +274,7 @@ describe('AppearanceProvider options flows', () => {
272274
showOptionalFields: true,
273275
socialButtonsPlacement: 'top',
274276
socialButtonsVariant: 'blockButton',
277+
elevation: 'flush',
275278
},
276279
}}
277280
>
@@ -289,6 +292,7 @@ describe('AppearanceProvider options flows', () => {
289292
expect(result.current.parsedOptions.showOptionalFields).toBe(true);
290293
expect(result.current.parsedOptions.socialButtonsPlacement).toBe('top');
291294
expect(result.current.parsedOptions.socialButtonsVariant).toBe('blockButton');
295+
expect(result.current.parsedOptions.elevation).toBe('flush');
292296
});
293297

294298
it('sets the parsedOptions correctly from the globalAppearance and appearance prop', () => {
@@ -306,6 +310,7 @@ describe('AppearanceProvider options flows', () => {
306310
showOptionalFields: false,
307311
socialButtonsPlacement: 'bottom',
308312
socialButtonsVariant: 'iconButton',
313+
elevation: 'flush',
309314
},
310315
}}
311316
appearance={{
@@ -319,6 +324,7 @@ describe('AppearanceProvider options flows', () => {
319324
showOptionalFields: true,
320325
socialButtonsPlacement: 'top',
321326
socialButtonsVariant: 'blockButton',
327+
elevation: 'raised',
322328
},
323329
}}
324330
>
@@ -336,6 +342,7 @@ describe('AppearanceProvider options flows', () => {
336342
expect(result.current.parsedOptions.showOptionalFields).toBe(true);
337343
expect(result.current.parsedOptions.socialButtonsPlacement).toBe('top');
338344
expect(result.current.parsedOptions.socialButtonsVariant).toBe('blockButton');
345+
expect(result.current.parsedOptions.elevation).toBe('raised');
339346
});
340347

341348
it('removes the base theme when simpleStyles is passed to globalAppearance', () => {

packages/ui/src/customizables/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { makeResponsive } from './makeResponsive';
66
import { sanitizeDomProps } from './sanitizeDomProps';
77

88
export * from './Flow';
9-
export { AppearanceProvider, useAppearance } from './AppearanceContext';
9+
export { AppearanceContext, AppearanceProvider, useAppearance } from './AppearanceContext';
1010
export { descriptors } from './elementDescriptors';
1111
export { localizationKeys, useLocalizations } from '../localization';
1212
export type { LocalizationKey } from '../localization';

packages/ui/src/customizables/parseAppearance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const defaultOptions: ParsedOptions = {
5252
animations: true,
5353
unsafe_disableDevelopmentModeWarnings: false,
5454
autoFocus: true,
55+
elevation: 'raised',
5556
};
5657

5758
const defaultCaptchaOptions: ParsedCaptcha = {

packages/ui/src/elements/Card/CardClerkAndPagesTag.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22

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

2929
return (
3030
<Box
31+
elementDescriptor={descriptors.footerItem}
3132
sx={[
3233
{
3334
width: '100%',
Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,102 @@
11
import React from 'react';
22

3-
import { Col, descriptors, generateFlowPartClassname, useAppearance } from '../../customizables';
3+
import { AppearanceContext, Col, descriptors, generateFlowPartClassname, useAppearance } from '../../customizables';
44
import type { ElementDescriptor } from '../../customizables/elementDescriptors';
5-
import type { PropsOfComponent } from '../../styledSystem';
5+
import type { InternalTheme, PropsOfComponent } from '../../styledSystem';
66
import { mqu } from '../../styledSystem';
77
import { ApplicationLogo } from '../ApplicationLogo';
88
import { useFlowMetadata } from '../contexts';
9+
import { ModalContext } from '../Modal';
910

10-
type CardRootProps = PropsOfComponent<typeof Col>;
11+
// Flush overrides injected into parsedElements after baseTheme but before user overrides.
12+
const getFlushElements = (t: InternalTheme) => ({
13+
cardBox: {
14+
borderWidth: 0,
15+
borderRadius: 0,
16+
boxShadow: 'none',
17+
overflow: 'visible',
18+
},
19+
card: {
20+
borderWidth: 0,
21+
borderRadius: 0,
22+
boxShadow: 'none',
23+
backgroundColor: 'transparent',
24+
paddingInline: 0,
25+
paddingBlock: 0,
26+
marginBlockStart: 0,
27+
marginInline: 0,
28+
},
29+
footer: {
30+
background: 'transparent',
31+
marginTop: t.space.$8,
32+
paddingTop: 0,
33+
rowGap: t.space.$8,
34+
'>:first-of-type': {
35+
padding: 0,
36+
},
37+
'>:not(:first-of-type)': {
38+
borderTopWidth: 0,
39+
padding: 0,
40+
},
41+
},
42+
});
43+
44+
type CardRootProps = PropsOfComponent<typeof Col> & {
45+
/**
46+
* Override the visual elevation for this card instance.
47+
* When omitted, falls back to `appearance.options.elevation` for page-mounted
48+
* components, and `'raised'` for modals.
49+
* Profile and popover card roots pass `'raised'` explicitly to opt out of flush.
50+
*/
51+
elevation?: 'raised' | 'flush';
52+
};
1153
export const CardRoot = React.forwardRef<HTMLDivElement, CardRootProps>((props, ref) => {
12-
const { sx, children, ...rest } = props;
54+
const { sx, children, elevation: elevationProp, ...rest } = props;
1355
const appearance = useAppearance();
1456
const flowMetadata = useFlowMetadata();
1557

58+
const rawModalCtx = React.useContext(ModalContext);
59+
const isModal = rawModalCtx !== undefined;
60+
// Explicit prop wins; modals always raised; otherwise use appearance option
61+
const elevation = elevationProp ?? (isModal ? 'raised' : appearance.parsedOptions.elevation);
62+
const isFlush = elevation === 'flush';
63+
64+
const augmentedAppearance = React.useMemo(() => {
65+
if (!isFlush) {
66+
return appearance;
67+
}
68+
const flushElements = getFlushElements(appearance.parsedInternalTheme);
69+
const newParsedElements = [appearance.parsedElements[0], flushElements, ...appearance.parsedElements.slice(1)];
70+
return { ...appearance, parsedElements: newParsedElements };
71+
}, [appearance, isFlush]);
72+
73+
const cardBox = (
74+
<Col
75+
elementDescriptor={[descriptors.cardBox, props.elementDescriptor as ElementDescriptor]}
76+
className={generateFlowPartClassname(flowMetadata)}
77+
ref={ref}
78+
data-elevation={isFlush ? 'flush' : undefined}
79+
sx={[
80+
t => ({
81+
isolation: 'isolate',
82+
maxWidth: `calc(100vw - ${t.sizes.$10})`,
83+
width: t.sizes.$100,
84+
borderWidth: t.borderWidths.$normal,
85+
borderStyle: t.borderStyles.$solid,
86+
borderColor: t.colors.$borderAlpha150,
87+
borderRadius: t.radii.$xl,
88+
color: t.colors.$colorForeground,
89+
position: 'relative',
90+
overflow: 'hidden',
91+
}),
92+
sx,
93+
]}
94+
{...rest}
95+
>
96+
{children}
97+
</Col>
98+
);
99+
16100
return (
17101
<>
18102
{appearance.parsedOptions.logoPlacement === 'outside' && (
@@ -25,33 +109,11 @@ export const CardRoot = React.forwardRef<HTMLDivElement, CardRootProps>((props,
25109
})}
26110
/>
27111
)}
28-
<Col
29-
elementDescriptor={[descriptors.cardBox, props.elementDescriptor as ElementDescriptor]}
30-
className={generateFlowPartClassname(flowMetadata)}
31-
ref={ref}
32-
sx={[
33-
t => ({
34-
/**
35-
* All components should create their own stack context
36-
* https://developer.mozilla.org/en-US/docs/Web/CSS/isolation
37-
*/
38-
isolation: 'isolate',
39-
maxWidth: `calc(100vw - ${t.sizes.$10})`,
40-
width: t.sizes.$100,
41-
borderWidth: t.borderWidths.$normal,
42-
borderStyle: t.borderStyles.$solid,
43-
borderColor: t.colors.$borderAlpha150,
44-
borderRadius: t.radii.$xl,
45-
color: t.colors.$colorForeground,
46-
position: 'relative',
47-
overflow: 'hidden',
48-
}),
49-
sx,
50-
]}
51-
{...rest}
52-
>
53-
{children}
54-
</Col>
112+
{isFlush ? (
113+
<AppearanceContext.Provider value={{ value: augmentedAppearance }}>{cardBox}</AppearanceContext.Provider>
114+
) : (
115+
cardBox
116+
)}
55117
</>
56118
);
57119
});

0 commit comments

Comments
 (0)