Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): Add flush appearance option by alexcarpenter · Pull Request #8510 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Add flush appearance option by alexcarpenter · Pull Request #8510 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Add flush appearance option by alexcarpenter · Pull Request #8510 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): Add flush appearance option by alexcarpenter · Pull Request #8510 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): Add flush appearance option by alexcarpenter · Pull Request #8510 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(ui): Add flush appearance option by alexcarpenter · Pull Request #8510 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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