diff --git a/.changeset/card-elevation-option.md b/.changeset/card-elevation-option.md new file mode 100644 index 00000000000..c583700e6b1 --- /dev/null +++ b/.changeset/card-elevation-option.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": minor +--- + +Add `elevation` appearance option with `'raised'` (default) and `'flush'` values. When set to `flush`, card-based components render without border, box-shadow, border-radius, outer padding, and footer background, allowing them to sit flat against their container. Applies to ``, ``, ``, ``, ``, ``, ``, and session task components. Profile and popover components always render as raised. Modal components always render as raised regardless of this setting. diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 8cb62856aed..e835ed27530 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -281,6 +281,9 @@ 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) { @@ -288,6 +291,16 @@ function otherOptions() { } }); + 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( @@ -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 = { @@ -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 => { @@ -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 = {}; + if (initialElevation) { + initialAppearanceOptions.elevation = initialElevation; + } + if (initialDevWarnings === 'off') { + initialAppearanceOptions.unsafe_disableDevelopmentModeWarnings = true; + } await Clerk.load({ ...(componentControls.clerk.getProps() ?? {}), @@ -549,6 +595,7 @@ void (async () => { appearance: { ...(initialTheme ? { theme: initialTheme } : {}), ...presetToAppearance(initialPreset), + ...(Object.keys(initialAppearanceOptions).length ? { options: initialAppearanceOptions } : {}), }, }); renderCurrentRoute(); @@ -560,6 +607,7 @@ void (async () => { updateVariables(); } updateOtherOptions(); + updateAppearanceOptions(); } else { console.error(`Unknown route: "${route}".`); } diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 9591fe7e852..0a15fd55400 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -33,7 +33,7 @@ } - +
localization + +
diff --git a/packages/ui/src/customizables/AppearanceContext.tsx b/packages/ui/src/customizables/AppearanceContext.tsx index 8bfee6007b9..9d180bd9e77 100644 --- a/packages/ui/src/customizables/AppearanceContext.tsx +++ b/packages/ui/src/customizables/AppearanceContext.tsx @@ -23,4 +23,4 @@ const AppearanceProvider = (props: AppearanceProviderProps) => { return {props.children}; }; -export { AppearanceProvider, useAppearance }; +export { AppearanceContext, AppearanceProvider, useAppearance }; diff --git a/packages/ui/src/customizables/index.ts b/packages/ui/src/customizables/index.ts index e45ce741652..5b3d141a574 100644 --- a/packages/ui/src/customizables/index.ts +++ b/packages/ui/src/customizables/index.ts @@ -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'; diff --git a/packages/ui/src/customizables/parseAppearance.ts b/packages/ui/src/customizables/parseAppearance.ts index a61b883191f..fac6df2d01b 100644 --- a/packages/ui/src/customizables/parseAppearance.ts +++ b/packages/ui/src/customizables/parseAppearance.ts @@ -51,6 +51,7 @@ const defaultOptions: ParsedOptions = { shimmer: true, animations: true, unsafe_disableDevelopmentModeWarnings: false, + elevation: 'raised', }; const defaultCaptchaOptions: ParsedCaptcha = { diff --git a/packages/ui/src/elements/Card/CardRoot.tsx b/packages/ui/src/elements/Card/CardRoot.tsx index 18f1d4b88d9..5c81fe2fc1c 100644 --- a/packages/ui/src/elements/Card/CardRoot.tsx +++ b/packages/ui/src/elements/Card/CardRoot.tsx @@ -1,18 +1,100 @@ import React from 'react'; -import { Col, descriptors, generateFlowPartClassname, useAppearance } from '../../customizables'; +import { AppearanceContext, Col, descriptors, generateFlowPartClassname, useAppearance } from '../../customizables'; import type { ElementDescriptor } from '../../customizables/elementDescriptors'; import type { PropsOfComponent } from '../../styledSystem'; import { mqu } from '../../styledSystem'; import { ApplicationLogo } from '../ApplicationLogo'; import { useFlowMetadata } from '../contexts'; +import { ModalContext } from '../Modal'; -type CardRootProps = PropsOfComponent; +// Element style overrides for flush elevation. Injected into parsedElements at +// index 1 (after baseTheme, before user overrides) via AppearanceContext so they +// participate in the makeCustomizable cascade and can still be overridden by users. +const FLUSH_ELEMENTS = { + cardBox: { + borderWidth: 0, + borderRadius: 0, + boxShadow: 'none', + overflow: 'visible', + }, + card: { + borderWidth: 0, + borderRadius: 0, + boxShadow: 'none', + backgroundColor: 'transparent', + padding: 0, + marginBlockStart: 0, + marginInline: 0, + }, + footer: { + background: 'transparent', + marginTop: 0, + paddingTop: 0, + '>:first-of-type': { + paddingInline: 0, + }, + '>:not(:first-of-type)': { + borderTopWidth: 0, + paddingInline: 0, + }, + }, +}; + +type CardRootProps = PropsOfComponent & { + /** + * 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((props, ref) => { - const { sx, children, ...rest } = props; + const { sx, children, elevation: elevationProp, ...rest } = props; const appearance = useAppearance(); const flowMetadata = useFlowMetadata(); + const rawModalCtx = React.useContext(ModalContext); + const isModal = rawModalCtx !== undefined; + // Explicit prop wins; modals always raised; otherwise use appearance option + const elevation = elevationProp ?? (isModal ? 'raised' : appearance.parsedOptions.elevation); + const isFlush = elevation === 'flush'; + + const augmentedAppearance = React.useMemo(() => { + if (!isFlush) { + return appearance; + } + const newParsedElements = [appearance.parsedElements[0], FLUSH_ELEMENTS, ...appearance.parsedElements.slice(1)]; + return { ...appearance, parsedElements: newParsedElements }; + }, [appearance, isFlush]); + + const cardBox = ( + ({ + 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} + + ); + return ( <> {appearance.parsedOptions.logoPlacement === 'outside' && ( @@ -25,33 +107,11 @@ export const CardRoot = React.forwardRef((props, })} /> )} - ({ - /** - * 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} - + {isFlush ? ( + {cardBox} + ) : ( + cardBox + )} ); }); diff --git a/packages/ui/src/elements/PopoverCard.tsx b/packages/ui/src/elements/PopoverCard.tsx index 92ac88cf2a7..f46f1431998 100644 --- a/packages/ui/src/elements/PopoverCard.tsx +++ b/packages/ui/src/elements/PopoverCard.tsx @@ -28,6 +28,8 @@ const PopoverCardRoot = React.forwardRef< elementDescriptor={[descriptors.popoverBox, elementDescriptor as ElementDescriptor]} {...rest} ref={ref} + // Popover cards always render as raised — flush is scoped to simple card components + elevation='raised' sx={[ t => ({ width: t.sizes.$94, diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx index d700b46446f..42573898182 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx @@ -15,6 +15,8 @@ export const ProfileCardRoot = React.forwardRef ({ width: t.sizes.$220, diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 5cc35d313be..0d73f19ba19 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -985,6 +985,26 @@ export type Options = { * @default false */ unsafe_disableDevelopmentModeWarnings?: boolean; + + /** + * Controls the visual elevation of card-based components. + * + * - `'raised'` (default) — the card renders with its border, box-shadow, border-radius, and padding. + * - `'flush'` — removes the card border, box-shadow, border-radius, outer padding, and footer + * background so the component sits flat against its container. + * + * Applies to all card-based components including ``, ``, + * ``, ``, ``, + * ``, ``, and session task components. + * + * Does **not** affect profile components (``, ``) + * or popover components (``, ``), which always render as raised. + * + * When a component is opened as a modal, it always renders as raised regardless of this setting. + * + * @default 'raised' + */ + elevation?: 'raised' | 'flush'; }; export type CaptchaAppearanceOptions = {