From e4b5e80418e0b06047454b42b5096b6bf58b48d1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 19 May 2026 11:29:27 -0400 Subject: [PATCH 1/3] refactor(ui): Migrate to focus-visibile usage --- packages/ui/src/baseTheme.ts | 10 ++-- .../ui/src/customizables/classGeneration.ts | 18 ++++++- packages/ui/src/elements/ArrowBlockButton.tsx | 4 +- packages/ui/src/elements/InputGroup.tsx | 4 +- packages/ui/src/elements/Navbar.tsx | 2 +- packages/ui/src/elements/PhoneInput/index.tsx | 8 +-- packages/ui/src/elements/Tabs.tsx | 2 +- packages/ui/src/primitives/Button.tsx | 14 ++--- packages/ui/src/primitives/Link.tsx | 2 +- packages/ui/src/styledSystem/common.ts | 53 ++++++++++--------- packages/ui/src/themes/neobrutalism.ts | 8 +-- 11 files changed, 72 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/baseTheme.ts b/packages/ui/src/baseTheme.ts index ed85bffb7f0..d93391ba693 100644 --- a/packages/ui/src/baseTheme.ts +++ b/packages/ui/src/baseTheme.ts @@ -117,7 +117,7 @@ const clerkTheme: Appearance = { }, '&[data-color="primary"]': { boxShadow: BUTTON_SOLID_SHADOW(theme.colors.$primary500), - '&:focus': { + '&:focus-visible': { boxShadow: [ BUTTON_SOLID_SHADOW(theme.colors.$primary500), theme.shadows.$focusRing.replace('{{color}}', theme.colors.$colorRing), @@ -126,7 +126,7 @@ const clerkTheme: Appearance = { }, '&[data-color="danger"]': { boxShadow: BUTTON_SOLID_SHADOW(theme.colors.$danger500), - '&:focus': { + '&:focus-visible': { boxShadow: [ BUTTON_SOLID_SHADOW(theme.colors.$danger500), theme.shadows.$focusRing.replace('{{color}}', theme.colors.$dangerAlpha200), @@ -137,7 +137,7 @@ const clerkTheme: Appearance = { '&[data-variant="outline"]': { borderWidth: 0, boxShadow: BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), - '&:focus': { + '&:focus-visible': { boxShadow: [ BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), theme.shadows.$focusRing.replace('{{color}}', theme.colors.$colorRing), @@ -147,7 +147,7 @@ const clerkTheme: Appearance = { '&[data-variant="bordered"]': { borderWidth: 0, boxShadow: BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), - '&:focus': { + '&:focus-visible': { boxShadow: [ BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), theme.shadows.$focusRing.replace('{{color}}', theme.colors.$colorRing), @@ -214,7 +214,7 @@ const clerkTheme: Appearance = { }, selectSearchInput__countryCode: { boxShadow: 'none', - '&:focus': { boxShadow: 'none' }, + '&:focus-visible': { boxShadow: 'none' }, }, cardBox: { borderWidth: 0, diff --git a/packages/ui/src/customizables/classGeneration.ts b/packages/ui/src/customizables/classGeneration.ts index 403c13036d1..2e2bc4c9e4c 100644 --- a/packages/ui/src/customizables/classGeneration.ts +++ b/packages/ui/src/customizables/classGeneration.ts @@ -126,14 +126,28 @@ const getElementState = (props: PropsWithState | undefined): ElementState | unde const addStringClassname = (cn: string, val?: unknown) => (typeof val === 'string' ? cn + ' ' + val : cn); +const normalizeFocusSelectors = (val: Record): Record => { + let result = val; + for (const key of Object.keys(val)) { + if (key.includes(':focus') && !key.includes(':focus-visible') && !key.includes(':focus-within')) { + const newKey = key.replace(/:focus/g, ':focus-visible'); + if (!(newKey in val)) { + result = result === val ? { ...val } : result; + result[newKey] = val[key]; + } + } + } + return result; +}; + const addStyleRuleObject = (css: unknown[], val: unknown, specificity = 0) => { if (specificity) { if (val && typeof val === 'object') { - css.push({ ['&'.repeat(specificity)]: val }); + css.push({ ['&'.repeat(specificity)]: normalizeFocusSelectors(val as Record) }); } } else { if (val && typeof val === 'object') { - css.push(val); + css.push(normalizeFocusSelectors(val as Record)); } } }; diff --git a/packages/ui/src/elements/ArrowBlockButton.tsx b/packages/ui/src/elements/ArrowBlockButton.tsx index 7ebc83e5407..dadcb25bb5d 100644 --- a/packages/ui/src/elements/ArrowBlockButton.tsx +++ b/packages/ui/src/elements/ArrowBlockButton.tsx @@ -67,14 +67,14 @@ export const ArrowBlockButton = React.forwardRef diff --git a/packages/ui/src/elements/Navbar.tsx b/packages/ui/src/elements/Navbar.tsx index 538a83474cd..8baf13d07da 100644 --- a/packages/ui/src/elements/Navbar.tsx +++ b/packages/ui/src/elements/Navbar.tsx @@ -296,7 +296,7 @@ const NavButton = (props: NavButtonProps) => { '&:hover': { backgroundColor: isActive ? undefined : t.colors.$neutralAlpha25, }, - '&:focus': { + '&:focus-visible': { backgroundColor: isActive ? undefined : t.colors.$neutralAlpha50, }, }), diff --git a/packages/ui/src/elements/PhoneInput/index.tsx b/packages/ui/src/elements/PhoneInput/index.tsx index 7882e0623be..e46258865f2 100644 --- a/packages/ui/src/elements/PhoneInput/index.tsx +++ b/packages/ui/src/elements/PhoneInput/index.tsx @@ -78,8 +78,8 @@ const PhoneInputBase = forwardRef @@ -119,7 +119,7 @@ const PhoneInputBase = forwardRef { borderRadius: 0, padding: `${t.space.$2x5} ${t.space.$0x25}`, width: 'fit-content', - '&:hover, :focus': { backgroundColor: t.colors.$transparent, boxShadow: 'none' }, + '&:hover, &:focus-visible': { backgroundColor: t.colors.$transparent, boxShadow: 'none' }, }), sx, ]} diff --git a/packages/ui/src/primitives/Button.tsx b/packages/ui/src/primitives/Button.tsx index 28d1eb2ad68..c00d911c89c 100644 --- a/packages/ui/src/primitives/Button.tsx +++ b/packages/ui/src/primitives/Button.tsx @@ -86,7 +86,7 @@ const { applyVariants, filterProps } = createVariants( '&:hover': { backgroundColor: vars.accentHover, }, - '&:focus': props.hoverAsFocus + '&:focus-visible': props.hoverAsFocus ? { backgroundColor: vars.accentHover, } @@ -98,7 +98,7 @@ const { applyVariants, filterProps } = createVariants( borderColor: theme.colors.$borderAlpha150, color: theme.colors.$neutralAlpha600, '&:hover': { backgroundColor: theme.colors.$neutralAlpha50 }, - '&:focus': props.hoverAsFocus + '&:focus-visible': props.hoverAsFocus ? { backgroundColor: theme.colors.$neutralAlpha50, borderColor: theme.colors.$borderAlpha300 } : undefined, }, @@ -109,14 +109,16 @@ const { applyVariants, filterProps } = createVariants( color: vars.accentContrast, backgroundColor: vars.accent, '&:hover': { backgroundColor: vars.accentHover }, - '&:focus': props.hoverAsFocus + '&:focus-visible': props.hoverAsFocus ? { backgroundColor: vars.accentHover, borderColor: theme.colors.$borderAlpha300 } : undefined, }, ghost: { color: vars.accent, '&:hover': { backgroundColor: vars.alpha, color: vars.accentHover }, - '&:focus': props.hoverAsFocus ? { backgroundColor: vars.alpha, color: vars.accentHover } : undefined, + '&:focus-visible': props.hoverAsFocus + ? { backgroundColor: vars.alpha, color: vars.accentHover } + : undefined, }, link: { minHeight: 'fit-content', @@ -126,7 +128,7 @@ const { applyVariants, filterProps } = createVariants( padding: 0, color: theme.colors.$primary500, '&:hover': { textDecoration: 'underline' }, - '&:focus': props.hoverAsFocus ? { textDecoration: 'underline' } : undefined, + '&:focus-visible': props.hoverAsFocus ? { textDecoration: 'underline' } : undefined, }, linkDanger: { minHeight: 'fit-content', @@ -136,7 +138,7 @@ const { applyVariants, filterProps } = createVariants( padding: 0, color: theme.colors.$danger500, '&:hover': { textDecoration: 'underline' }, - '&:focus': props.hoverAsFocus ? { textDecoration: 'underline' } : undefined, + '&:focus-visible': props.hoverAsFocus ? { textDecoration: 'underline' } : undefined, }, unstyled: {}, roundWrapper: { padding: 0, margin: 0, height: 'unset', width: 'unset', minHeight: 'unset' }, diff --git a/packages/ui/src/primitives/Link.tsx b/packages/ui/src/primitives/Link.tsx index 0709cb54e2b..1bd36ee4f44 100644 --- a/packages/ui/src/primitives/Link.tsx +++ b/packages/ui/src/primitives/Link.tsx @@ -36,7 +36,7 @@ const { applyVariants, filterProps } = createVariants(theme => ({ }, focusRing: { true: { - '&:focus': { + '&:focus-visible': { outline: 'none', ...common.focusRing(theme), }, diff --git a/packages/ui/src/styledSystem/common.ts b/packages/ui/src/styledSystem/common.ts index 7b0cf4f9eac..810f3d800e2 100644 --- a/packages/ui/src/styledSystem/common.ts +++ b/packages/ui/src/styledSystem/common.ts @@ -94,34 +94,37 @@ const borderVariants = (t: InternalTheme, props?: any) => { boxShadow: hoverBoxShadow, }, }; - const focusStyles = - props?.focusRing === false - ? {} - : { - '&:focus': { - borderColor: hoverBorderColor, - WebkitTapHighlightColor: 'transparent', - boxShadow: [ - hoverBoxShadow, - t.shadows.$focusRing.replace( - '{{color}}', - !props?.hasError ? t.colors.$borderAlpha150 : (t.colors.$dangerAlpha200 as string), - ), - ].toString(), - }, - }; - const borderColor = !props?.hasError ? !props?.hasWarning - ? t.colors.$borderAlpha150 // Default border color - : t.colors.$warningAlpha300 // Warning border color - : t.colors.$dangerAlpha500; // Error border color + ? t.colors.$borderAlpha150 + : t.colors.$warningAlpha300 + : t.colors.$dangerAlpha500; const boxShadow = !props?.hasError ? !props?.hasWarning - ? t.colors.$borderAlpha100 // Default box shadow color - : t.colors.$warningAlpha50 // Warning box shadow color - : t.colors.$borderAlpha150; // Error box shadow color + ? t.colors.$borderAlpha100 + : t.colors.$warningAlpha50 + : t.colors.$borderAlpha150; + + const defaultBoxShadow = t.shadows.$input.replace('{{color}}', boxShadow); + + const focusStyleValues = { + borderColor: hoverBorderColor, + WebkitTapHighlightColor: 'transparent', + boxShadow: [ + hoverBoxShadow, + t.shadows.$focusRing.replace( + '{{color}}', + !props?.hasError ? t.colors.$borderAlpha150 : (t.colors.$dangerAlpha200 as string), + ), + ].toString(), + }; + const focusStyles = + props?.focusRing === false + ? {} + : { + '&:focus-visible': focusStyleValues, + }; return { normal: { @@ -129,7 +132,7 @@ const borderVariants = (t: InternalTheme, props?: any) => { borderWidth: t.borderWidths.$normal, borderStyle: t.borderStyles.$solid, borderColor, - boxShadow: t.shadows.$input.replace('{{color}}', boxShadow), + boxShadow: defaultBoxShadow, transitionProperty: t.transitionProperty.$common, transitionTimingFunction: t.transitionTiming.$common, transitionDuration: t.transitionDuration.$focusRing, @@ -158,7 +161,7 @@ const focusRingStyles = (t: InternalTheme) => { const focusRing = (t: InternalTheme) => { return { - '&:focus': { + '&:focus-visible': { ...focusRingStyles(t), }, } as const; diff --git a/packages/ui/src/themes/neobrutalism.ts b/packages/ui/src/themes/neobrutalism.ts index 1b14ee69676..ecd296d3035 100644 --- a/packages/ui/src/themes/neobrutalism.ts +++ b/packages/ui/src/themes/neobrutalism.ts @@ -3,7 +3,7 @@ import { createTheme } from './createTheme'; const buttonStyle = { boxShadow: '3px 3px 0px #000', border: '2px solid #000', - '&:focus': { + '&:focus-visible': { boxShadow: '4px 4px 0px #000', border: '2px solid #000', transform: 'scale(1.01)', @@ -49,7 +49,7 @@ export const neobrutalism = createTheme({ ...buttonStyle, ...shadowStyle, transition: 'all 0.2s ease-in-out', - '&:focus': { + '&:focus-visible': { boxShadow: '4px 4px 0px #000', border: '2px solid #000', transform: 'scale(1.01)', @@ -69,7 +69,7 @@ export const neobrutalism = createTheme({ formFieldInput: { ...shadowStyle, transition: 'all 0.2s ease-in-out', - '&:focus': { + '&:focus-visible': { boxShadow: '4px 4px 0px #000', border: '2px solid #000', transform: 'scale(1.01)', @@ -102,7 +102,7 @@ export const neobrutalism = createTheme({ footerActionLink: { fontWeight: '700', borderBottom: '3px solid', - '&:focus': { + '&:focus-visible': { boxShadow: 'none', }, }, From de65da46c13f5b3cc6f98ac4d66e75ad7c54d832 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 19 May 2026 12:16:59 -0400 Subject: [PATCH 2/3] add fallback and remove normalization --- packages/ui/src/baseTheme.ts | 36 +++++++++++++++++++ .../ui/src/customizables/classGeneration.ts | 18 ++-------- packages/ui/src/styledSystem/common.ts | 11 ++++++ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/baseTheme.ts b/packages/ui/src/baseTheme.ts index d93391ba693..998c9a94202 100644 --- a/packages/ui/src/baseTheme.ts +++ b/packages/ui/src/baseTheme.ts @@ -117,6 +117,15 @@ const clerkTheme: Appearance = { }, '&[data-color="primary"]': { boxShadow: BUTTON_SOLID_SHADOW(theme.colors.$primary500), + '&:focus': { + boxShadow: [ + BUTTON_SOLID_SHADOW(theme.colors.$primary500), + theme.shadows.$focusRing.replace('{{color}}', theme.colors.$colorRing), + ].toString(), + }, + '&:focus:not(:focus-visible)': { + boxShadow: BUTTON_SOLID_SHADOW(theme.colors.$primary500), + }, '&:focus-visible': { boxShadow: [ BUTTON_SOLID_SHADOW(theme.colors.$primary500), @@ -126,6 +135,15 @@ const clerkTheme: Appearance = { }, '&[data-color="danger"]': { boxShadow: BUTTON_SOLID_SHADOW(theme.colors.$danger500), + '&:focus': { + boxShadow: [ + BUTTON_SOLID_SHADOW(theme.colors.$danger500), + theme.shadows.$focusRing.replace('{{color}}', theme.colors.$dangerAlpha200), + ].toString(), + }, + '&:focus:not(:focus-visible)': { + boxShadow: BUTTON_SOLID_SHADOW(theme.colors.$danger500), + }, '&:focus-visible': { boxShadow: [ BUTTON_SOLID_SHADOW(theme.colors.$danger500), @@ -137,6 +155,15 @@ const clerkTheme: Appearance = { '&[data-variant="outline"]': { borderWidth: 0, boxShadow: BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), + '&:focus': { + boxShadow: [ + BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), + theme.shadows.$focusRing.replace('{{color}}', theme.colors.$colorRing), + ].toString(), + }, + '&:focus:not(:focus-visible)': { + boxShadow: BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), + }, '&:focus-visible': { boxShadow: [ BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), @@ -147,6 +174,15 @@ const clerkTheme: Appearance = { '&[data-variant="bordered"]': { borderWidth: 0, boxShadow: BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), + '&:focus': { + boxShadow: [ + BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), + theme.shadows.$focusRing.replace('{{color}}', theme.colors.$colorRing), + ].toString(), + }, + '&:focus:not(:focus-visible)': { + boxShadow: BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), + }, '&:focus-visible': { boxShadow: [ BUTTON_OUTLINE_SHADOW(theme.colors.$borderAlpha100), diff --git a/packages/ui/src/customizables/classGeneration.ts b/packages/ui/src/customizables/classGeneration.ts index 2e2bc4c9e4c..403c13036d1 100644 --- a/packages/ui/src/customizables/classGeneration.ts +++ b/packages/ui/src/customizables/classGeneration.ts @@ -126,28 +126,14 @@ const getElementState = (props: PropsWithState | undefined): ElementState | unde const addStringClassname = (cn: string, val?: unknown) => (typeof val === 'string' ? cn + ' ' + val : cn); -const normalizeFocusSelectors = (val: Record): Record => { - let result = val; - for (const key of Object.keys(val)) { - if (key.includes(':focus') && !key.includes(':focus-visible') && !key.includes(':focus-within')) { - const newKey = key.replace(/:focus/g, ':focus-visible'); - if (!(newKey in val)) { - result = result === val ? { ...val } : result; - result[newKey] = val[key]; - } - } - } - return result; -}; - const addStyleRuleObject = (css: unknown[], val: unknown, specificity = 0) => { if (specificity) { if (val && typeof val === 'object') { - css.push({ ['&'.repeat(specificity)]: normalizeFocusSelectors(val as Record) }); + css.push({ ['&'.repeat(specificity)]: val }); } } else { if (val && typeof val === 'object') { - css.push(normalizeFocusSelectors(val as Record)); + css.push(val); } } }; diff --git a/packages/ui/src/styledSystem/common.ts b/packages/ui/src/styledSystem/common.ts index 810f3d800e2..9abc7b7173c 100644 --- a/packages/ui/src/styledSystem/common.ts +++ b/packages/ui/src/styledSystem/common.ts @@ -123,6 +123,11 @@ const borderVariants = (t: InternalTheme, props?: any) => { props?.focusRing === false ? {} : { + '&:focus': focusStyleValues, + '&:focus:not(:focus-visible)': { + borderColor, + boxShadow: defaultBoxShadow, + }, '&:focus-visible': focusStyleValues, }; @@ -161,6 +166,12 @@ const focusRingStyles = (t: InternalTheme) => { const focusRing = (t: InternalTheme) => { return { + '&:focus': { + ...focusRingStyles(t), + }, + '&:focus:not(:focus-visible)': { + boxShadow: 'none', + }, '&:focus-visible': { ...focusRingStyles(t), }, From 4fbca133465594947ffdedce235a08e7e0c1bbab Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 19 May 2026 12:24:26 -0400 Subject: [PATCH 3/3] add changeset --- .changeset/migrate-focus-visible.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-focus-visible.md diff --git a/.changeset/migrate-focus-visible.md b/.changeset/migrate-focus-visible.md new file mode 100644 index 00000000000..8e1e5074dd0 --- /dev/null +++ b/.changeset/migrate-focus-visible.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Migrate from `:focus` to `:focus-visible` so focus rings only appear during keyboard navigation