diff --git a/.claude/skills/add-sparkle-component/references/sparkle-design-features.md b/.claude/skills/add-sparkle-component/references/sparkle-design-features.md
index 3ed7949..02148ca 100644
--- a/.claude/skills/add-sparkle-component/references/sparkle-design-features.md
+++ b/.claude/skills/add-sparkle-component/references/sparkle-design-features.md
@@ -184,6 +184,19 @@ Components include appropriate ARIA attributes:
- Logical tab order
- Skip links for navigation
+#### Focus ring color (form components)
+
+Form components (`input`, `textarea`, `select`, `checkbox`, `radio`, …) drive the
+focus ring color from their error state — do **not** hardcode the blue ring on the
+error path:
+
+- Normal state → `ring-[var(--color-ring-normal)]` (blue)
+- Error state (`isInvalid`) → `ring-negative-500`, matching the negative border
+
+Define the ring color in the `isInvalid` variant (`false` → ring-normal, `true` →
+ring-negative-500) rather than in the base class, so only one ring color ever
+applies. `switch` has no error state, so it keeps the normal ring only.
+
### Color Contrast
All color combinations meet WCAG 2.1 Level AA standards:
diff --git a/src/components/ui/checkbox/index.tsx b/src/components/ui/checkbox/index.tsx
index e2a125c..a95d387 100644
--- a/src/components/ui/checkbox/index.tsx
+++ b/src/components/ui/checkbox/index.tsx
@@ -37,7 +37,9 @@ const checkboxItemVariants = cva(
const checkboxRootVariants = cva(
[
"rounded-xs border-2 transition-colors",
- "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-[var(--color-ring-normal)] [.group:focus-visible_&]:ring-offset-2",
+ // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。
+ // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error).
+ "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-offset-2",
].join(" "),
{
variants: {
@@ -49,11 +51,13 @@ const checkboxRootVariants = cva(
isInvalid: {
true: [
"border-negative-500",
+ "[.group:focus-visible_&]:ring-negative-500",
"[.group[data-state=checked]_&]:bg-negative-500 [.group[data-state=checked]_&]:border-none",
"[.group[data-state=indeterminate]_&]:bg-negative-500 [.group[data-state=indeterminate]_&]:border-none",
].join(" "),
false: [
"border-neutral-500",
+ "[.group:focus-visible_&]:ring-[var(--color-ring-normal)]",
"[.group[data-state=checked]_&]:bg-primary-500 [.group[data-state=checked]_&]:border-none",
"[.group[data-state=indeterminate]_&]:bg-primary-500 [.group[data-state=indeterminate]_&]:border-none",
].join(" "),
diff --git a/src/components/ui/input/index.test.tsx b/src/components/ui/input/index.test.tsx
index 4f4923c..944d842 100644
--- a/src/components/ui/input/index.test.tsx
+++ b/src/components/ui/input/index.test.tsx
@@ -266,6 +266,28 @@ describe("Input", () => {
// Then: invalid状態のクラスが保持される(実際のCVAクラス名)
expect(container?.className).toContain("border-negative-500");
});
+
+ it("uses the negative focus ring on error (isInvalid + isFocused)", () => {
+ // Given: エラー状態でフォーカスされた Input
+ testContainer.render();
+ const container = testContainer.getContainer().firstElementChild;
+
+ // Then: フォーカスリングは negative 色になり、通常の青リングは付かない
+ expect(container?.className).toContain("ring-negative-500");
+ expect(container?.className).not.toContain(
+ "ring-[var(--color-ring-normal)]"
+ );
+ });
+
+ it("uses the normal focus ring when valid (isFocused only)", () => {
+ // Given: 通常状態でフォーカスされた Input
+ testContainer.render();
+ const container = testContainer.getContainer().firstElementChild;
+
+ // Then: フォーカスリングは通常の青色になり、negative リングは付かない
+ expect(container?.className).toContain("ring-[var(--color-ring-normal)]");
+ expect(container?.className).not.toContain("ring-negative-500");
+ });
});
describe("Accessibility", () => {
diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx
index 4f250f7..76ff2cc 100644
--- a/src/components/ui/input/index.tsx
+++ b/src/components/ui/input/index.tsx
@@ -29,11 +29,23 @@ const inputVariants = cva(
false: "",
},
isFocused: {
- true: "ring-2 ring-[var(--color-ring-normal)] ring-offset-2 outline-hidden",
+ true: "ring-2 ring-offset-2 outline-hidden",
false: "",
},
},
compoundVariants: [
+ // フォーカスリング色。通常は青、エラー時は枠線と同じ negative に揃える。
+ // en: Focus ring color. Blue normally; on error it matches the negative border.
+ {
+ isFocused: true,
+ isInvalid: false,
+ className: "ring-[var(--color-ring-normal)]",
+ },
+ {
+ isFocused: true,
+ isInvalid: true,
+ className: "ring-negative-500",
+ },
// 通常状態
{
isInvalid: false,
diff --git a/src/components/ui/radio/index.tsx b/src/components/ui/radio/index.tsx
index f4408b9..1884023 100644
--- a/src/components/ui/radio/index.tsx
+++ b/src/components/ui/radio/index.tsx
@@ -54,7 +54,9 @@ const radioItemVariants = cva(
const radioIndicatorVariants = cva(
[
"flex items-center justify-center rounded-full border border-2 transition-colors",
- "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-[var(--color-ring-normal)] [.group:focus-visible_&]:ring-offset-2",
+ // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。
+ // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error).
+ "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-offset-2",
].join(" "),
{
variants: {
@@ -64,8 +66,8 @@ const radioIndicatorVariants = cva(
lg: "h-6 w-6",
},
isInvalid: {
- true: "border-negative-500 [.group[data-state=checked]_&]:border-negative-500",
- false: "border-neutral-500",
+ true: "border-negative-500 [.group:focus-visible_&]:ring-negative-500 [.group[data-state=checked]_&]:border-negative-500",
+ false: "border-neutral-500 [.group:focus-visible_&]:ring-[var(--color-ring-normal)]",
},
isDisabled: {
true: "",
diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx
index b8251d8..a433843 100644
--- a/src/components/ui/select/index.tsx
+++ b/src/components/ui/select/index.tsx
@@ -14,7 +14,9 @@ import { cn } from "@/lib/utils";
const selectTriggerVariants = cva(
[
"flex items-center justify-between w-full rounded-action border bg-white text-text-high transition-colors",
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring-normal)] focus-visible:ring-offset-2",
+ // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。
+ // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error).
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"overflow-hidden whitespace-nowrap",
].join(" "),
{
@@ -25,9 +27,9 @@ const selectTriggerVariants = cva(
lg: "h-12 py-1 pl-4 pr-2 gap-2 character-4-regular-pro",
},
isInvalid: {
- true: "bg-white border-negative-500 hover:border-negative-600 data-[state=open]:border-negative-600",
+ true: "bg-white border-negative-500 hover:border-negative-600 data-[state=open]:border-negative-600 focus-visible:ring-negative-500",
false:
- "border-neutral-500 hover:border-neutral-600 data-[state=open]:border-neutral-600",
+ "border-neutral-500 hover:border-neutral-600 data-[state=open]:border-neutral-600 focus-visible:ring-[var(--color-ring-normal)]",
},
isDisabled: {
true: "cursor-not-allowed bg-neutral-50 border-neutral-200 hover:border-neutral-200 text-text-disabled",
diff --git a/src/components/ui/textarea/index.tsx b/src/components/ui/textarea/index.tsx
index ea29bd8..0091d8f 100644
--- a/src/components/ui/textarea/index.tsx
+++ b/src/components/ui/textarea/index.tsx
@@ -15,7 +15,9 @@ import { cn } from "@/lib/utils";
*/
const textareaVariants = cva(
// ベーススタイル
- "flex w-full rounded-action border bg-white px-3 py-1 ring-offset-background placeholder:text-base-400 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--color-ring-normal)] focus-visible:ring-offset-2 resize",
+ // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。
+ // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error).
+ "flex w-full rounded-action border bg-white px-3 py-1 ring-offset-background placeholder:text-base-400 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 resize",
{
variants: {
// サイズバリアント(sm, md, lg)
@@ -26,9 +28,9 @@ const textareaVariants = cva(
},
// エラー状態のバリアント
isInvalid: {
- true: "border-negative-500 hover:border-negative-600 focus-visible:border-negative-600",
+ true: "border-negative-500 hover:border-negative-600 focus-visible:border-negative-600 focus-visible:ring-negative-500",
false:
- "border-neutral-500 hover:border-neutral-600 focus-visible:border-neutral-600",
+ "border-neutral-500 hover:border-neutral-600 focus-visible:border-neutral-600 focus-visible:ring-[var(--color-ring-normal)]",
},
// 無効状態のバリアント
isDisabled: {