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
2 changes: 2 additions & 0 deletions .changeset/mosaic-field-error-enter-animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
39 changes: 32 additions & 7 deletions packages/swingset/src/stories/field.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import { Input } from '@clerk/ui/mosaic/components/input';
required
aria-invalid={Boolean(error)}
/>
{error ? <Field.Error>{error}</Field.Error> : <Field.Description>Used for account notifications.</Field.Description>}
<Field.Description>Used for account notifications.</Field.Description>
<Field.Message>
<Field.Error>{error}</Field.Error>
</Field.Message>
</Field.Root>;
```

Expand All @@ -42,14 +45,36 @@ Field does not validate controls or render errors automatically. Its parts may a

Pass `visuallyHidden` to `Field.Label` when the surrounding UI already makes the label redundant. The label is still rendered and still associated with the control, so the control keeps its accessible name — unlike dropping the label and relying on a `placeholder`, which is not an accessible name. It applies the same style as the [VisuallyHidden](/components/visually-hidden) component.

### Error

<Story
name='WithError'
storyModule={FieldStories}
/>

Wrap `Field.Error` in `Field.Message` and pass the message as its children. `Field.Message` measures whichever message is showing and animates its own height to match, so it grows when a message arrives, follows the text when the message changes, and collapses after it clears. The caller never conditionally mounts either part. Under `prefers-reduced-motion` the height snaps and only the message fades.

`Field.Message` is also the field's live region. It stays mounted and carries `role='status'`, so a message that arrives while the user is in the control is announced politely, and a message that is fading out is hidden from assistive technology so only the incoming one is read. Pass `role='alert'` when a message must interrupt, for example a submit-time validation error.

### Success

<Story
name='WithFeedback'
storyModule={FieldStories}
/>

`Field.Success` is the positive counterpart. Render both inside one `Field.Message` and pass each its own message; when one replaces the other, the outgoing message fades out over the incoming one while the container height moves once from the old height to the new.

## Parts

| Part | Stable slot class | Description |
| ------------------- | ----------------------- | ------------------------------------------------- |
| `Field.Root` | `.cl-field-root` | Unstyled `div` and field context provider. |
| `Field.Label` | `.cl-field-label` | Native `label` associated with the field control. |
| `Field.Description` | `.cl-field-description` | Supporting `p` associated with the field control. |
| `Field.Error` | `.cl-field-error` | Associated error `p` with an alert icon. |
| Part | Stable slot class | Description |
| ------------------- | ----------------------- | --------------------------------------------------- |
| `Field.Root` | `.cl-field-root` | Unstyled `div` and field context provider. |
| `Field.Label` | `.cl-field-label` | Native `label` associated with the field control. |
| `Field.Description` | `.cl-field-description` | Supporting `p` associated with the field control. |
| `Field.Message` | `.cl-field-message` | Height-animated live region for the messages below. |
| `Field.Error` | `.cl-field-error` | Associated error `p` with an alert icon. |
| `Field.Success` | `.cl-field-success` | Associated success `p` with a check icon. |

## Styling

Expand Down
86 changes: 86 additions & 0 deletions packages/swingset/src/stories/field.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Button } from '@clerk/ui/mosaic/components/button';
import { Field } from '@clerk/ui/mosaic/components/field';
import { Input } from '@clerk/ui/mosaic/components/input';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import type { StoryMeta } from '@/lib/types';

Expand All @@ -21,6 +23,12 @@ const styles = stylex.create({
gap: 8,
maxWidth: 384,
},
errorStack: {
display: 'grid',
gap: 8,
maxWidth: 384,
width: '100%',
},
});

export function Default() {
Expand Down Expand Up @@ -49,3 +57,81 @@ export function VisuallyHiddenLabel() {
</Field.Root>
);
}

const errorMessages = [
'Enter a valid email address.',
'This email address is already in use. Sign in instead, or use a different address to create a new account.',
];

export function WithError() {
const [error, setError] = React.useState<string | null>(null);
return (
<div {...stylex.props(styles.errorStack)}>
<Field.Root invalid={error !== null}>
<Field.Label>Email address</Field.Label>
<Input
name='email'
type='email'
placeholder='you@example.com'
/>
<Field.Description>Used for account notifications.</Field.Description>
<Field.Message>
<Field.Error>{error}</Field.Error>
</Field.Message>
</Field.Root>
<Button
variant='outline'
onClick={() => setError(current => (current === null ? errorMessages[0] : null))}
>
Toggle short error
</Button>
<Button
variant='outline'
onClick={() => setError(current => (current === null ? errorMessages[1] : null))}
>
Toggle long error
</Button>
</div>
);
}

export function WithFeedback() {
const [feedback, setFeedback] = React.useState<'error' | 'success' | null>(null);
return (
<div {...stylex.props(styles.errorStack)}>
<Field.Root invalid={feedback === 'error'}>
<Field.Label>Current password</Field.Label>
<Input
name='currentPassword'
type='password'
/>
<Field.Message>
<Field.Error>
{feedback === 'error'
? 'The password you entered is incorrect. Check for typos and try again, or reset your password if you have forgotten it.'
: null}
</Field.Error>
<Field.Success>{feedback === 'success' ? 'Password verified.' : null}</Field.Success>
</Field.Message>
</Field.Root>
<Button
variant='outline'
onClick={() => setFeedback('error')}
>
Show error
</Button>
<Button
variant='outline'
onClick={() => setFeedback('success')}
>
Show success
</Button>
<Button
variant='outline'
onClick={() => setFeedback(null)}
>
Clear
</Button>
</div>
);
}
5 changes: 4 additions & 1 deletion packages/swingset/src/stories/otp.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ Inside a `Field.Root`, the field's `disabled`, `required`, and `invalid` flow in
>
<Field.Label>Verification code</Field.Label>
<Otp name='code' />
{error ? <Field.Error>{error}</Field.Error> : <Field.Description>Didn’t receive a code? Resend</Field.Description>}
<Field.Description>Didn’t receive a code? Resend</Field.Description>
<Field.Message>
<Field.Error>{error}</Field.Error>
</Field.Message>
</Field.Root>
```

Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/blocks/destructive/destructive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ export function Destructive({
disabled={isDeleting}
onChange={event => setTypedValue(event.target.value)}
/>
{errorMessage ? <Field.Error>{errorMessage}</Field.Error> : null}
<Field.Message>
<Field.Error>{errorMessage}</Field.Error>
</Field.Message>
</Field.Root>
</form>
</Card.Content>
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/src/mosaic/components/field/field.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,29 @@ export function useRegisterFieldPartId(
}, [id, setIds]);
}

interface FieldMessageContextValue {
register: (key: symbol, element: HTMLElement | null) => void;
}

const FieldMessageContext = React.createContext<FieldMessageContextValue | null>(null);

export function FieldMessageProvider({ register, children }: React.PropsWithChildren<FieldMessageContextValue>) {
const context = React.useMemo<FieldMessageContextValue>(() => ({ register }), [register]);
return <FieldMessageContext.Provider value={context}>{children}</FieldMessageContext.Provider>;
}

export function useRegisterFieldMessage(open: boolean): React.RefCallback<HTMLElement> {
const register = React.useContext(FieldMessageContext)?.register;
const key = React.useRef(Symbol('field-message'));

return React.useCallback(
(element: HTMLElement | null) => {
register?.(key.current, open ? element : null);
},
[register, open],
);
}

interface FieldControlProps {
id?: string;
disabled?: boolean;
Expand Down
47 changes: 42 additions & 5 deletions packages/ui/src/mosaic/components/field/field.styles.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, space } from '../../tokens.stylex';
import { colorVars, durationVars, easingVars, fontWeightVars, space } from '../../tokens.stylex';

const ROOT_GAP = space['2'];

export const styles = stylex.create({
root: {
gap: space['2'],
gap: ROOT_GAP,
display: 'flex',
flexDirection: 'column',
},
Expand All @@ -18,15 +20,50 @@ export const styles = stylex.create({
description: {
color: colorVars['--cl-color-neutral-faded'],
},
error: {
messageRoot: {
overflow: 'clip',
alignContent: 'start',
display: 'grid',
Comment on lines +25 to +26

@maxyinger maxyinger Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any purpose for this grid? looks like we're popping exiting animation out of flow?

position: 'relative',
transitionDuration: durationVars['--cl-duration-slow'],
transitionProperty: {
default: 'height, margin-top',
'@media (prefers-reduced-motion: reduce)': 'none',
},
transitionTimingFunction: easingVars['--cl-ease-enter'],
height: {
default: 'var(--_cl-field-message-height)',
':where(:not([data-open]), [data-starting-style])': 0,
},
marginTop: {
default: 0,
':where(:not([data-open]), [data-starting-style])': `calc(-1 * ${ROOT_GAP})`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh to account for the gap jump. nice.

},
},
feedback: {
inset: { default: null, ':where([data-ending-style])': '0 0 auto' },
gap: space['1'],
alignItems: 'flex-start',
color: colorVars['--cl-color-negative'],
display: 'flex',
opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
position: { default: null, ':where([data-ending-style])': 'absolute' },
textWrap: 'pretty',
transitionDuration: durationVars['--cl-duration-base'],
transitionProperty: 'opacity',
transitionTimingFunction: 'linear',
Comment on lines +51 to +53

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes I try to avoid these frames where they cut the content with different exiting or entering easings/delays/durations or adding a slight scale anim. more of a reach goal tho/might add too much flavor to it. Already feels nice with the gap jump accounted for ❤️

Image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, was toying around with a mask during reveal, but didn't land on anything solid. something to maybe polish in a future PR https://x.com/blvdmitry/status/2000860557298045163

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooo I like that. yea def a future improvement and not a blocker

},
error: {
color: colorVars['--cl-color-negative'],
},
success: {
color: colorVars['--cl-color-positive'],
},
errorIcon: {
feedbackIcon: {
flexShrink: 0,
height: '1lh',
},
});

export const dynamic = stylex.create({
messageHeight: (height: number) => ({ '--_cl-field-message-height': `${height}px` }),
});
Loading
Loading