From 846c95c9decbc66d75eae76dd5a92ccd70fbd6da Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 26 Aug 2026 13:14:20 -0400 Subject: [PATCH 1/6] feat(ui): add Mosaic Otp component Styled verification-code field built on the headless Otp primitive. --- .changeset/blue-otters-count.md | 5 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 18 ++ .../swingset/src/stories/otp.component.mdx | 86 +++++++++ .../src/stories/otp.component.stories.tsx | 111 ++++++++++++ .../mosaic/components/input/input.styles.ts | 65 +------ .../ui/src/mosaic/components/input/input.tsx | 3 +- .../ui/src/mosaic/components/otp/index.ts | 2 + .../src/mosaic/components/otp/otp.styles.ts | 91 ++++++++++ .../ui/src/mosaic/components/otp/otp.test.tsx | 164 ++++++++++++++++++ packages/ui/src/mosaic/components/otp/otp.tsx | 88 ++++++++++ packages/ui/src/mosaic/styles/index.ts | 2 + .../src/mosaic/utils/input-surface.styles.ts | 66 +++++++ 13 files changed, 637 insertions(+), 65 deletions(-) create mode 100644 .changeset/blue-otters-count.md create mode 100644 packages/swingset/src/stories/otp.component.mdx create mode 100644 packages/swingset/src/stories/otp.component.stories.tsx create mode 100644 packages/ui/src/mosaic/components/otp/index.ts create mode 100644 packages/ui/src/mosaic/components/otp/otp.styles.ts create mode 100644 packages/ui/src/mosaic/components/otp/otp.test.tsx create mode 100644 packages/ui/src/mosaic/components/otp/otp.tsx create mode 100644 packages/ui/src/mosaic/utils/input-surface.styles.ts diff --git a/.changeset/blue-otters-count.md b/.changeset/blue-otters-count.md new file mode 100644 index 00000000000..407a1941a3a --- /dev/null +++ b/.changeset/blue-otters-count.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add the Mosaic `Otp` component: a styled verification-code field built on the headless `Otp` primitive. It renders one box per character, matches `Input`'s border, hover, and focus treatment, supports `sm`/`md`/`lg` sizes, and colours every box for a `neutral`, `success`, or `error` status. Inside a `Field.Root` it picks up the field's `disabled` and `invalid` state. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3a52fbae376..e71eeef694c 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -49,6 +49,7 @@ const docModules: Record> = { heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), + otp: dynamic(() => import('../stories/otp.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), section: dynamic(() => import('../stories/section.mdx')), text: dynamic(() => import('../stories/text.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dbf534ce294..afa71730afe 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -61,6 +61,14 @@ import { } from '../stories/item.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; +import { + Default as OtpComponentDefault, + Disabled as OtpComponentDisabled, + Error as OtpComponentError, + meta as otpComponentMeta, + Sizes as OtpComponentSizes, + Success as OtpComponentSuccess, +} from '../stories/otp.component.stories'; import { meta as otpMeta } from '../stories/otp.stories'; import { Alignment as PopoverComponentAlignment, @@ -239,6 +247,15 @@ const headingModule: StoryModule = { const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault }; +const otpComponentModule: StoryModule = { + meta: otpComponentMeta, + Default: OtpComponentDefault, + Sizes: OtpComponentSizes, + Success: OtpComponentSuccess, + Error: OtpComponentError, + Disabled: OtpComponentDisabled, +}; + const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors }; const fieldModule: StoryModule = { @@ -388,6 +405,7 @@ export const registry: StoryModule[] = [ headingModule, iconModule, menuComponentModule, + otpComponentModule, popoverComponentModule, sectionModule, textModule, diff --git a/packages/swingset/src/stories/otp.component.mdx b/packages/swingset/src/stories/otp.component.mdx new file mode 100644 index 00000000000..a517db8fd2e --- /dev/null +++ b/packages/swingset/src/stories/otp.component.mdx @@ -0,0 +1,86 @@ +import * as OtpStories from './otp.component.stories'; + +# OTP + +The Mosaic `Otp` is the verification-code field: one styled box per character, built on the headless [OTP primitive](/primitives/otp). Focus advances as the code is typed, `Backspace` walks back, and a pasted code spreads across the boxes. Each box carries the same border, hover, and focus treatment as `Input`. + +## Playground + + + +## Props + + + +## Usage + +`length` is the only required prop. Give the group an accessible name with `aria-label`, or place it in a `Field.Root` with a `Field.Label`. + +```tsx +import { Otp } from '@clerk/ui/mosaic/components/otp'; + + verify(code)} +/>; +``` + +Inside a `Field.Root`, the field's `disabled` and `invalid` flow into the boxes, and the label and messages are associated with the group: + +```tsx + + Verification code + + {error ? {error} : Didn’t receive a code? Resend} + +``` + +`status` overrides that: `'error'` marks every box invalid and colours it negative, `'success'` colours a verified code positive. + +--- + +## Examples + +### Sizes + + + +### Success + + + +### Error + + + +### Disabled + + + +## Parts + +| Part | Stable slot class | Description | +| ---- | ----------------- | ---------------------------------------------- | +| Root | `.cl-otp` | The `role="group"` holding the boxes. | +| Slot | `.cl-otp-slot` | One `input` per character, one box in the row. | + +Both carry `data-size` and `data-status`, plus `data-disabled` when disabled. Each slot additionally carries the primitive's `data-active` (holds focus) and `data-filled` (holds a character). diff --git a/packages/swingset/src/stories/otp.component.stories.tsx b/packages/swingset/src/stories/otp.component.stories.tsx new file mode 100644 index 00000000000..e2f94695cbb --- /dev/null +++ b/packages/swingset/src/stories/otp.component.stories.tsx @@ -0,0 +1,111 @@ +import { Field } from '@clerk/ui/mosaic/components/field'; +import type { OtpProps } from '@clerk/ui/mosaic/components/otp'; +import { Otp } from '@clerk/ui/mosaic/components/otp'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './otp.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'OTP', + source: 'packages/ui/src/mosaic/components/otp/otp.tsx', + styles: { + _variants: { + size: { sm: {}, md: {}, lg: {} }, + status: { neutral: {}, success: {}, error: {} }, + }, + _defaultVariants: { + size: 'md', + status: 'neutral', + }, + }, +}; + +const stackStyles = { + display: 'grid', + gap: 8, + justifyItems: 'start', +} as const; + +function knobsAsProps(props: Record) { + return props as unknown as OtpProps; +} + +export function Default(props: Record) { + return ( + + ); +} + +export function Sizes() { + return ( +
+ + + +
+ ); +} + +export function Success() { + return ( + + + Success + + ); +} + +export function Error() { + return ( + + + Incorrect code + + ); +} + +export function Disabled() { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/components/input/input.styles.ts b/packages/ui/src/mosaic/components/input/input.styles.ts index dd4001b40e2..935d5003a0a 100644 --- a/packages/ui/src/mosaic/components/input/input.styles.ts +++ b/packages/ui/src/mosaic/components/input/input.styles.ts @@ -1,68 +1,11 @@ import * as stylex from '@stylexjs/stylex'; -import { - colorVars, - durationVars, - easingVars, - fontFamilyVars, - fontWeightVars, - radiusVars, - space, - typeScaleVars, -} from '../../tokens.stylex'; - -const disabledBackgroundColor = `color-mix(in oklab, ${colorVars['--cl-color-primary']} 5%, transparent)`; -const hoverBorderColor = 'light-dark(#bebebe, #525252)'; -const focusShadow = '0 0 0 3px light-dark(rgb(23 23 23 / 8%), rgb(255 255 255 / 8%))'; -const invalidFocusShadow = `0 0 0 3px light-dark( - color-mix(in oklab, ${colorVars['--cl-color-negative']} 12%, transparent), - color-mix(in oklab, ${colorVars['--cl-color-negative']} 15%, transparent) -)`; +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; export const styles = stylex.create({ base: { - borderColor: { - default: colorVars['--cl-color-border'], - ':focus-visible': hoverBorderColor, - ':focus-visible:where([aria-invalid="true"])': colorVars['--cl-color-negative'], - ':where([aria-invalid="true"])': colorVars['--cl-color-negative'], - '@media (hover: hover)': { - ':hover:not([aria-invalid="true"])': hoverBorderColor, - }, - }, - borderStyle: 'solid', - borderWidth: '1px', - outline: { - default: 'none', - '@media (forced-colors: active)': { - default: null, - ':focus-visible': '2px solid CanvasText', - }, - }, - backgroundColor: colorVars['--cl-color-input'], - boxShadow: { - default: null, - ':focus-visible': focusShadow, - ':focus-visible:where([aria-invalid="true"])': invalidFocusShadow, - }, display: 'block', fontFamily: fontFamilyVars['--cl-font-family-sans'], - outlineOffset: { - default: null, - '@media (forced-colors: active)': { - default: null, - ':focus-visible': '2px', - }, - }, - transitionDuration: { - default: durationVars['--cl-duration-base'], - ':focus-visible': durationVars['--cl-duration-fast'], - }, - transitionProperty: 'color, background-color, border-color, box-shadow', - transitionTimingFunction: { - default: 'linear', - ':focus-visible': `linear, linear, linear, ${easingVars['--cl-ease-default']}`, - }, minWidth: 0, width: '100%', '::file-selector-button': { @@ -80,12 +23,6 @@ export const styles = stylex.create({ color: colorVars['--cl-color-input-placeholder'], }, }, - disabled: { - backgroundColor: disabledBackgroundColor, - cursor: 'not-allowed', - opacity: 0.5, - pointerEvents: 'none', - }, }); export const sizes = stylex.create({ diff --git a/packages/ui/src/mosaic/components/input/input.tsx b/packages/ui/src/mosaic/components/input/input.tsx index 86ac057c1cf..76b2c5ef1ce 100644 --- a/packages/ui/src/mosaic/components/input/input.tsx +++ b/packages/ui/src/mosaic/components/input/input.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { inputSurface } from '../../utils/input-surface.styles'; import { reset } from '../../utils/reset.styles'; import { useOptionalFieldControlProps } from '../field/field.context'; import { sizes, styles } from './input.styles'; @@ -52,7 +53,7 @@ export const Input = React.forwardRef(function Mos 'aria-describedby': fieldProps?.['aria-describedby'] ?? ariaDescribedBy, ...mergeStyleProps( themeProps('input', { size, disabled }), - stylex.props(reset.base, styles.base, sizes[size], disabled && styles.disabled), + stylex.props(reset.base, inputSurface.base, styles.base, sizes[size], disabled && inputSurface.disabled), className, style, ), diff --git a/packages/ui/src/mosaic/components/otp/index.ts b/packages/ui/src/mosaic/components/otp/index.ts new file mode 100644 index 00000000000..906d579e34a --- /dev/null +++ b/packages/ui/src/mosaic/components/otp/index.ts @@ -0,0 +1,2 @@ +export { Otp } from './otp'; +export type { OtpProps, OtpSize, OtpStatus } from './otp'; diff --git a/packages/ui/src/mosaic/components/otp/otp.styles.ts b/packages/ui/src/mosaic/components/otp/otp.styles.ts new file mode 100644 index 00000000000..d5d81fe6571 --- /dev/null +++ b/packages/ui/src/mosaic/components/otp/otp.styles.ts @@ -0,0 +1,91 @@ +import * as stylex from '@stylexjs/stylex'; + +import { + colorVars, + fontFamilyVars, + fontWeightVars, + radiusVars, + space, + targetVars, + typeScaleVars, +} from '../../tokens.stylex'; + +const positiveFocusShadow = `0 0 0 3px light-dark( + color-mix(in oklab, ${colorVars['--cl-color-positive']} 12%, transparent), + color-mix(in oklab, ${colorVars['--cl-color-positive']} 15%, transparent) +)`; + +export const styles = stylex.create({ + root: { + alignItems: 'center', + display: 'flex', + width: 'fit-content', + }, + slot: { + padding: 0, + caretColor: colorVars['--cl-color-primary'], + color: colorVars['--cl-color-card-foreground'], + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontVariantNumeric: 'tabular-nums', + fontWeight: fontWeightVars['--cl-font-medium'], + textAlign: 'center', + }, + // The success cell restates the focus shadow so a verified code keeps its colour + // while focused, mirroring how the shared surface handles `aria-invalid`. + success: { + borderColor: { + default: colorVars['--cl-color-positive'], + ':focus-visible': colorVars['--cl-color-positive'], + '@media (hover: hover)': { + ':hover': colorVars['--cl-color-positive'], + }, + }, + boxShadow: { + default: null, + ':focus-visible': positiveFocusShadow, + }, + }, + touchTarget: { + minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, + minWidth: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, + }, +}); + +export const rootSizes = stylex.create({ + sm: { gap: space['1.5'] }, + md: { gap: space['2'] }, + lg: { gap: space['2'] }, +}); + +export const slotSizes = stylex.create({ + sm: { + borderRadius: radiusVars['--cl-radius-md'], + fontSize: { + default: typeScaleVars['--cl-text-sm-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-sm-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-sm-leading'], + height: space['8'], + width: space['8'], + }, + md: { + borderRadius: radiusVars['--cl-radius-lg'], + fontSize: { + default: typeScaleVars['--cl-text-base-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-base-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-base-leading'], + height: space['10'], + width: space['10'], + }, + lg: { + borderRadius: radiusVars['--cl-radius-lg'], + fontSize: { + default: typeScaleVars['--cl-text-lg-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-lg-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-lg-leading'], + height: space['12'], + width: space['12'], + }, +}); diff --git a/packages/ui/src/mosaic/components/otp/otp.test.tsx b/packages/ui/src/mosaic/components/otp/otp.test.tsx new file mode 100644 index 00000000000..ef7c8344711 --- /dev/null +++ b/packages/ui/src/mosaic/components/otp/otp.test.tsx @@ -0,0 +1,164 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Field } from '../field'; +import { Otp } from './otp'; + +const slots = () => screen.getAllByRole('textbox'); + +describe('Mosaic Otp', () => { + it('renders one slot per character and applies the default variants', () => { + render( + , + ); + + const group = screen.getByRole('group', { name: 'Verification code' }); + expect(group).toHaveClass('cl-otp'); + expect(group).toHaveAttribute('data-size', 'md'); + expect(group).toHaveAttribute('data-status', 'neutral'); + + expect(slots()).toHaveLength(6); + for (const slot of slots()) { + expect(slot).toHaveClass('cl-otp-slot'); + expect(slot).toHaveAttribute('data-size', 'md'); + } + }); + + it.each(['sm', 'md', 'lg'] as const)('reflects the %s size on the group and every slot', size => { + render( + , + ); + expect(screen.getByRole('group', { name: 'Code' })).toHaveAttribute('data-size', size); + for (const slot of slots()) { + expect(slot).toHaveAttribute('data-size', size); + } + }); + + it('marks every slot invalid when the status is error', () => { + render( + , + ); + expect(screen.getByRole('group', { name: 'Code' })).toHaveAttribute('data-status', 'error'); + for (const slot of slots()) { + expect(slot).toHaveAttribute('data-status', 'error'); + expect(slot).toHaveAttribute('aria-invalid', 'true'); + } + }); + + it('reflects the success status without marking slots invalid', () => { + render( + , + ); + expect(screen.getByRole('group', { name: 'Code' })).toHaveAttribute('data-status', 'success'); + for (const slot of slots()) { + expect(slot).toHaveAttribute('data-status', 'success'); + expect(slot).not.toHaveAttribute('aria-invalid'); + } + }); + + it('reflects and forwards the disabled state', () => { + render( + , + ); + expect(screen.getByRole('group', { name: 'Code' })).toHaveAttribute('data-disabled', ''); + for (const slot of slots()) { + expect(slot).toBeDisabled(); + expect(slot).toHaveAttribute('data-disabled', ''); + } + }); + + it('advances focus as characters are typed and reports completion', async () => { + const user = userEvent.setup(); + const onComplete = vi.fn(); + render( + , + ); + + await user.click(slots()[0]); + await user.keyboard('123'); + + expect(slots().map(slot => (slot as HTMLInputElement).value)).toEqual(['1', '2', '3']); + expect(onComplete).toHaveBeenCalledWith('123'); + }); + + it('takes disabled and invalid from an enclosing Field', () => { + render( + + + , + ); + const group = screen.getByRole('group', { name: 'Code' }); + expect(group).toHaveAttribute('data-status', 'error'); + expect(group).toHaveAttribute('data-disabled', ''); + expect(slots()[0]).toBeDisabled(); + }); + + it('associates the Field label and messages with the group', () => { + render( + + Verification code + + Check your email. + , + ); + const group = screen.getByRole('group', { name: 'Verification code' }); + expect(group).toHaveAccessibleDescription('Check your email.'); + }); + + it('merges consumer className and inline styles onto the group', () => { + render( + , + ); + const group = screen.getByRole('group', { name: 'Code' }); + expect(group).toHaveClass('cl-otp', 'my-otp'); + expect(group).toHaveStyle({ marginTop: '8px' }); + }); + + it('submits the combined value under the given name', () => { + render( + , + ); + expect(document.querySelector('input[name="code"]')).toHaveValue('123'); + }); +}); diff --git a/packages/ui/src/mosaic/components/otp/otp.tsx b/packages/ui/src/mosaic/components/otp/otp.tsx new file mode 100644 index 00000000000..8e0009cd561 --- /dev/null +++ b/packages/ui/src/mosaic/components/otp/otp.tsx @@ -0,0 +1,88 @@ +import type { OtpProps as PrimitiveOtpProps } from '@clerk/headless/otp'; +import { Otp as Primitive } from '@clerk/headless/otp'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { inputSurface } from '../../utils/input-surface.styles'; +import { reset } from '../../utils/reset.styles'; +import { useOptionalFieldControlProps } from '../field/field.context'; +import { rootSizes, slotSizes, styles } from './otp.styles'; + +/** How the entered code currently reads back to the user. */ +export type OtpStatus = 'neutral' | 'success' | 'error'; + +export type OtpSize = 'sm' | 'md' | 'lg'; + +export interface OtpProps extends Omit { + size?: OtpSize; + /** Colours every slot for the verification outcome. Defaults to the enclosing `Field`'s validity. */ + status?: OtpStatus; +} + +function OtpSlots({ size, status }: { size: OtpSize; status: OtpStatus }) { + const { slots, disabled } = Primitive.useOtp(); + + return slots.map(slot => ( + + )); +} + +/** + * A fixed-length verification code field: one styled box per character, with focus + * advancing as the code is typed and a pasted code spread across the boxes. + */ +export function Otp({ + size = 'md', + status: statusProp, + disabled: disabledProp, + className, + style, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...rest +}: OtpProps): React.ReactElement { + const fieldProps = useOptionalFieldControlProps({ + disabled: disabledProp, + ariaLabelledBy, + ariaDescribedBy, + }); + const disabled = fieldProps?.disabled ?? disabledProp ?? false; + const status = statusProp ?? (fieldProps?.['aria-invalid'] === true ? 'error' : 'neutral'); + + return ( + + + + ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index a623b1dd2bf..bfe621d8eb7 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -65,6 +65,8 @@ export type { InputProps } from '../components/input'; export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; export { Menu } from '../components/menu'; +export { Otp } from '../components/otp'; +export type { OtpProps, OtpSize, OtpStatus } from '../components/otp'; export type { MenuContentProps, MenuItemProps, diff --git a/packages/ui/src/mosaic/utils/input-surface.styles.ts b/packages/ui/src/mosaic/utils/input-surface.styles.ts new file mode 100644 index 00000000000..02d3317a8c4 --- /dev/null +++ b/packages/ui/src/mosaic/utils/input-surface.styles.ts @@ -0,0 +1,66 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars } from '../tokens.stylex'; + +// The border + focus treatment shared by every Mosaic text-entry surface (`Input`, +// `Otp`). Focus is marked with a border colour change and a soft box-shadow rather +// than the system focus ring, so an entry field reads as a field rather than a +// control; the ring only comes back under `forced-colors`. +const hoverBorderColor = 'light-dark(#bebebe, #525252)'; +const focusShadow = '0 0 0 3px light-dark(rgb(23 23 23 / 8%), rgb(255 255 255 / 8%))'; +const invalidFocusShadow = `0 0 0 3px light-dark( + color-mix(in oklab, ${colorVars['--cl-color-negative']} 12%, transparent), + color-mix(in oklab, ${colorVars['--cl-color-negative']} 15%, transparent) +)`; +const disabledBackgroundColor = `color-mix(in oklab, ${colorVars['--cl-color-primary']} 5%, transparent)`; + +export const inputSurface = stylex.create({ + base: { + borderColor: { + default: colorVars['--cl-color-border'], + ':focus-visible': hoverBorderColor, + ':focus-visible:where([aria-invalid="true"])': colorVars['--cl-color-negative'], + ':where([aria-invalid="true"])': colorVars['--cl-color-negative'], + '@media (hover: hover)': { + ':hover:not([aria-invalid="true"])': hoverBorderColor, + }, + }, + borderStyle: 'solid', + borderWidth: '1px', + outline: { + default: 'none', + '@media (forced-colors: active)': { + default: null, + ':focus-visible': '2px solid CanvasText', + }, + }, + backgroundColor: colorVars['--cl-color-input'], + boxShadow: { + default: null, + ':focus-visible': focusShadow, + ':focus-visible:where([aria-invalid="true"])': invalidFocusShadow, + }, + outlineOffset: { + default: null, + '@media (forced-colors: active)': { + default: null, + ':focus-visible': '2px', + }, + }, + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':focus-visible': durationVars['--cl-duration-fast'], + }, + transitionProperty: 'color, background-color, border-color, box-shadow', + transitionTimingFunction: { + default: 'linear', + ':focus-visible': `linear, linear, linear, ${easingVars['--cl-ease-default']}`, + }, + }, + disabled: { + backgroundColor: disabledBackgroundColor, + cursor: 'not-allowed', + opacity: 0.5, + pointerEvents: 'none', + }, +}); From 605043c7d345759cb0e103a2c1bd97c8bb9803fe Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 26 Aug 2026 13:19:32 -0400 Subject: [PATCH 2/6] refactor(ui): rename inputSurface to inputStyles Document the Otp value/onValueChange controlled API. --- .../swingset/src/stories/otp.component.mdx | 30 ++++++++++++++++++- .../ui/src/mosaic/components/input/input.tsx | 4 +-- packages/ui/src/mosaic/components/otp/otp.tsx | 6 ++-- ...nput-surface.styles.ts => input.styles.ts} | 8 ++--- 4 files changed, 37 insertions(+), 11 deletions(-) rename packages/ui/src/mosaic/utils/{input-surface.styles.ts => input.styles.ts} (85%) diff --git a/packages/swingset/src/stories/otp.component.mdx b/packages/swingset/src/stories/otp.component.mdx index a517db8fd2e..69054c181f1 100644 --- a/packages/swingset/src/stories/otp.component.mdx +++ b/packages/swingset/src/stories/otp.component.mdx @@ -13,7 +13,22 @@ The Mosaic `Otp` is the verification-code field: one styled box per character, b ## Props - + void', default: '—' }, + { name: 'onComplete', type: '(value: string) => void', default: '—' }, + { name: 'pattern', type: "'numeric' | 'alpha' | 'alphanumeric'", default: "'numeric'" }, + { name: 'mask', type: 'boolean', default: 'false' }, + { name: 'name', type: 'string', default: '—' }, + { name: 'disabled', type: 'boolean', default: 'false' }, + ]} +/> + +Every prop of the headless [OTP primitive](/primitives/otp) passes through. ## Usage @@ -29,6 +44,19 @@ import { Otp } from '@clerk/ui/mosaic/components/otp'; />; ``` +The value is uncontrolled by default. Pass `value` with `onValueChange` to control it. There is no `onChange`: the boxes are separate inputs, so the whole code is reported as one string. + +```tsx +const [code, setCode] = useState(''); + +; +``` + Inside a `Field.Root`, the field's `disabled` and `invalid` flow into the boxes, and the label and messages are associated with the group: ```tsx diff --git a/packages/ui/src/mosaic/components/input/input.tsx b/packages/ui/src/mosaic/components/input/input.tsx index 76b2c5ef1ce..f42f463506d 100644 --- a/packages/ui/src/mosaic/components/input/input.tsx +++ b/packages/ui/src/mosaic/components/input/input.tsx @@ -4,7 +4,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; -import { inputSurface } from '../../utils/input-surface.styles'; +import { inputStyles } from '../../utils/input.styles'; import { reset } from '../../utils/reset.styles'; import { useOptionalFieldControlProps } from '../field/field.context'; import { sizes, styles } from './input.styles'; @@ -53,7 +53,7 @@ export const Input = React.forwardRef(function Mos 'aria-describedby': fieldProps?.['aria-describedby'] ?? ariaDescribedBy, ...mergeStyleProps( themeProps('input', { size, disabled }), - stylex.props(reset.base, inputSurface.base, styles.base, sizes[size], disabled && inputSurface.disabled), + stylex.props(reset.base, inputStyles.base, styles.base, sizes[size], disabled && inputStyles.disabled), className, style, ), diff --git a/packages/ui/src/mosaic/components/otp/otp.tsx b/packages/ui/src/mosaic/components/otp/otp.tsx index 8e0009cd561..c5083166380 100644 --- a/packages/ui/src/mosaic/components/otp/otp.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.tsx @@ -4,7 +4,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { mergeStyleProps, themeProps } from '../../props'; -import { inputSurface } from '../../utils/input-surface.styles'; +import { inputStyles } from '../../utils/input.styles'; import { reset } from '../../utils/reset.styles'; import { useOptionalFieldControlProps } from '../field/field.context'; import { rootSizes, slotSizes, styles } from './otp.styles'; @@ -32,12 +32,12 @@ function OtpSlots({ size, status }: { size: OtpSize; status: OtpStatus }) { themeProps('otp-slot', { size, status, disabled }), stylex.props( reset.base, - inputSurface.base, + inputStyles.base, styles.slot, styles.touchTarget, slotSizes[size], status === 'success' && styles.success, - disabled && inputSurface.disabled, + disabled && inputStyles.disabled, ), )} /> diff --git a/packages/ui/src/mosaic/utils/input-surface.styles.ts b/packages/ui/src/mosaic/utils/input.styles.ts similarity index 85% rename from packages/ui/src/mosaic/utils/input-surface.styles.ts rename to packages/ui/src/mosaic/utils/input.styles.ts index 02d3317a8c4..f87e0b3c8a2 100644 --- a/packages/ui/src/mosaic/utils/input-surface.styles.ts +++ b/packages/ui/src/mosaic/utils/input.styles.ts @@ -2,10 +2,8 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, easingVars } from '../tokens.stylex'; -// The border + focus treatment shared by every Mosaic text-entry surface (`Input`, -// `Otp`). Focus is marked with a border colour change and a soft box-shadow rather -// than the system focus ring, so an entry field reads as a field rather than a -// control; the ring only comes back under `forced-colors`. +// Focus is marked with a border colour change and a soft box-shadow rather than the +// system focus ring, so a text-entry field reads as a field rather than a control. const hoverBorderColor = 'light-dark(#bebebe, #525252)'; const focusShadow = '0 0 0 3px light-dark(rgb(23 23 23 / 8%), rgb(255 255 255 / 8%))'; const invalidFocusShadow = `0 0 0 3px light-dark( @@ -14,7 +12,7 @@ const invalidFocusShadow = `0 0 0 3px light-dark( )`; const disabledBackgroundColor = `color-mix(in oklab, ${colorVars['--cl-color-primary']} 5%, transparent)`; -export const inputSurface = stylex.create({ +export const inputStyles = stylex.create({ base: { borderColor: { default: colorVars['--cl-color-border'], From b9c3fdcedf69f46a9b8c59d9401853374ae1e369 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 26 Aug 2026 16:17:28 -0400 Subject: [PATCH 3/6] feat(ui): default Otp length to 6 and drop className/style --- .../swingset/src/components/PropTable.tsx | 6 ++++-- .../swingset/src/stories/otp.component.mdx | 14 +++++-------- .../src/stories/otp.component.stories.tsx | 7 ------- .../ui/src/mosaic/components/otp/otp.test.tsx | 20 ++++++------------- packages/ui/src/mosaic/components/otp/otp.tsx | 12 +++++------ 5 files changed, 21 insertions(+), 38 deletions(-) diff --git a/packages/swingset/src/components/PropTable.tsx b/packages/swingset/src/components/PropTable.tsx index 616c811f6e0..022d7b80142 100644 --- a/packages/swingset/src/components/PropTable.tsx +++ b/packages/swingset/src/components/PropTable.tsx @@ -15,6 +15,8 @@ interface ExtraProp { interface PropTableProps { meta: StoryMeta; extra?: ExtraProp[]; + /** Set for a component that does not forward `className`/`style`. @default true */ + styleProps?: boolean; } const STYLEX_ROWS: ExtraProp[] = [ @@ -22,7 +24,7 @@ const STYLEX_ROWS: ExtraProp[] = [ { name: 'style', type: 'CSSProperties' }, ]; -export function PropTable({ meta, extra = [] }: PropTableProps) { +export function PropTable({ meta, extra = [], styleProps = true }: PropTableProps) { const playground = usePlayground(); const variants = meta.styles?._variants ?? {}; const defaults = meta.styles?._defaultVariants ?? {}; @@ -38,7 +40,7 @@ export function PropTable({ meta, extra = [] }: PropTableProps) { return { name, type, default: defDisplay }; }), ...extra, - ...STYLEX_ROWS, + ...(styleProps ? STYLEX_ROWS : []), ]; return ( diff --git a/packages/swingset/src/stories/otp.component.mdx b/packages/swingset/src/stories/otp.component.mdx index 69054c181f1..09a9b41694f 100644 --- a/packages/swingset/src/stories/otp.component.mdx +++ b/packages/swingset/src/stories/otp.component.mdx @@ -15,8 +15,9 @@ The Mosaic `Otp` is the verification-code field: one styled box per character, b void', default: '—' }, @@ -28,17 +29,16 @@ The Mosaic `Otp` is the verification-code field: one styled box per character, b ]} /> -Every prop of the headless [OTP primitive](/primitives/otp) passes through. +Every prop of the headless [OTP primitive](/primitives/otp) passes through. The boxes are styled through their `.cl-otp` and `.cl-otp-slot` classes, so `Otp` takes no `className` or `style`. ## Usage -`length` is the only required prop. Give the group an accessible name with `aria-label`, or place it in a `Field.Root` with a `Field.Label`. +`length` defaults to `6`. Give the group an accessible name with `aria-label`, or place it in a `Field.Root` with a `Field.Label`. ```tsx import { Otp } from '@clerk/ui/mosaic/components/otp'; verify(code)} />; @@ -50,7 +50,6 @@ The value is uncontrolled by default. Pass `value` with `onValueChange` to contr const [code, setCode] = useState(''); Verification code - + {error ? {error} : Didn’t receive a code? Resend} ``` diff --git a/packages/swingset/src/stories/otp.component.stories.tsx b/packages/swingset/src/stories/otp.component.stories.tsx index e2f94695cbb..22ea9313afb 100644 --- a/packages/swingset/src/stories/otp.component.stories.tsx +++ b/packages/swingset/src/stories/otp.component.stories.tsx @@ -38,7 +38,6 @@ export function Default(props: Record) { return ( ); @@ -48,19 +47,16 @@ export function Sizes() { return (
@@ -102,7 +96,6 @@ export function Error() { export function Disabled() { return ( { } }); + it('defaults to six slots', () => { + render(); + + expect(slots()).toHaveLength(6); + }); + it.each(['sm', 'md', 'lg'] as const)('reflects the %s size on the group and every slot', size => { render( { expect(group).toHaveAccessibleDescription('Check your email.'); }); - it('merges consumer className and inline styles onto the group', () => { - render( - , - ); - const group = screen.getByRole('group', { name: 'Code' }); - expect(group).toHaveClass('cl-otp', 'my-otp'); - expect(group).toHaveStyle({ marginTop: '8px' }); - }); - it('submits the combined value under the given name', () => { render( { +export interface OtpProps extends Omit { + /** The number of boxes in the code. @default 6 */ + length?: number; size?: OtpSize; /** Colours every slot for the verification outcome. Defaults to the enclosing `Field`'s validity. */ status?: OtpStatus; @@ -49,11 +51,10 @@ function OtpSlots({ size, status }: { size: OtpSize; status: OtpStatus }) { * advancing as the code is typed and a pasted code spread across the boxes. */ export function Otp({ + length = 6, size = 'md', status: statusProp, disabled: disabledProp, - className, - style, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...rest @@ -68,14 +69,13 @@ export function Otp({ return ( From 82c22e5309b8aee61242865b7e78d235e48bb669 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 26 Aug 2026 16:22:49 -0400 Subject: [PATCH 4/6] fix(ui): honor a direct aria-invalid on Otp --- .../ui/src/mosaic/components/otp/otp.test.tsx | 15 +++++++++++++++ packages/ui/src/mosaic/components/otp/otp.tsx | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/otp/otp.test.tsx b/packages/ui/src/mosaic/components/otp/otp.test.tsx index 6fd241b96c6..c4b8d057790 100644 --- a/packages/ui/src/mosaic/components/otp/otp.test.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.test.tsx @@ -29,6 +29,21 @@ describe('Mosaic Otp', () => { } }); + it('marks every slot invalid when aria-invalid is passed directly', () => { + render( + , + ); + + expect(screen.getByRole('group', { name: 'Code' })).toHaveAttribute('data-status', 'error'); + for (const slot of slots()) { + expect(slot).toHaveAttribute('aria-invalid', 'true'); + } + }); + it('defaults to six slots', () => { render(); diff --git a/packages/ui/src/mosaic/components/otp/otp.tsx b/packages/ui/src/mosaic/components/otp/otp.tsx index 60711fe6e68..dc1f39196ce 100644 --- a/packages/ui/src/mosaic/components/otp/otp.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.tsx @@ -55,17 +55,20 @@ export function Otp({ size = 'md', status: statusProp, disabled: disabledProp, + 'aria-invalid': ariaInvalidProp, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...rest }: OtpProps): React.ReactElement { const fieldProps = useOptionalFieldControlProps({ disabled: disabledProp, + ariaInvalid: ariaInvalidProp, ariaLabelledBy, ariaDescribedBy, }); const disabled = fieldProps?.disabled ?? disabledProp ?? false; - const status = statusProp ?? (fieldProps?.['aria-invalid'] === true ? 'error' : 'neutral'); + const ariaInvalid = fieldProps?.['aria-invalid'] ?? ariaInvalidProp; + const status = statusProp ?? (ariaInvalid === true || ariaInvalid === 'true' ? 'error' : 'neutral'); return ( Date: Wed, 26 Aug 2026 17:05:23 -0400 Subject: [PATCH 5/6] Apply suggestion from @alexcarpenter --- .changeset/blue-otters-count.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/blue-otters-count.md b/.changeset/blue-otters-count.md index 407a1941a3a..a845151cc84 100644 --- a/.changeset/blue-otters-count.md +++ b/.changeset/blue-otters-count.md @@ -1,5 +1,2 @@ --- -'@clerk/ui': minor --- - -Add the Mosaic `Otp` component: a styled verification-code field built on the headless `Otp` primitive. It renders one box per character, matches `Input`'s border, hover, and focus treatment, supports `sm`/`md`/`lg` sizes, and colours every box for a `neutral`, `success`, or `error` status. Inside a `Field.Root` it picks up the field's `disabled` and `invalid` state. From 347bada9e0c11537c63d5fd99df437d88f4abb6b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 27 Aug 2026 07:42:48 -0400 Subject: [PATCH 6/6] Update otp.styles.ts Co-authored-by: Austin Calvelage --- packages/ui/src/mosaic/components/otp/otp.styles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/otp/otp.styles.ts b/packages/ui/src/mosaic/components/otp/otp.styles.ts index d5d81fe6571..1235840dad4 100644 --- a/packages/ui/src/mosaic/components/otp/otp.styles.ts +++ b/packages/ui/src/mosaic/components/otp/otp.styles.ts @@ -22,7 +22,6 @@ export const styles = stylex.create({ width: 'fit-content', }, slot: { - padding: 0, caretColor: colorVars['--cl-color-primary'], color: colorVars['--cl-color-card-foreground'], fontFamily: fontFamilyVars['--cl-font-family-sans'],