diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx
index 801405f31..55f89f9ee 100644
--- a/docs/docs/react/components/InView.mdx
+++ b/docs/docs/react/components/InView.mdx
@@ -4,13 +4,11 @@ import { InView } from '@modern-kit/react';
`InView`는 `IntersectionObserver`를 선언적으로 활용 할 수 있는 컴포넌트입니다.
-`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다.
-
-`Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다.
+관찰 대상이 `Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다.
-`calledOnce`를 활용하면 `onIntersectStart`와 `onIntersectEnd`를 각각 한번씩 호출 할 수 있습니다.
+다형성을 지원하기 때문에 `as` 속성을 통해 특정 요소로 렌더링할 수 있습니다.
-Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고)
+`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다.
@@ -20,14 +18,17 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고
## Interface
```ts title="typescript"
interface InViewProps extends UseIntersectionObserverProps {
- children: JSX.Element;
+ children: React.ReactNode;
}
```
```ts title="typescript"
-const InView: ({ children, ...props }: InViewProps) => JSX.Element
+const InView: PolyForwardComponent<"div", InViewProps, React.ElementType>
```
## Usage
+### Default
+- 기본적으로 `div`로 감싸지며, 해당 `div`를 관찰 대상으로 설정합니다.
+- 해당 `div`가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다.
```tsx title="typescript"
import { InView } from '@modern-kit/react';
@@ -42,12 +43,41 @@ const Example = () => {
return (
- {/* ... */}
+
+ Box1
+
+
;
+ );
+};
+```
+
+### asChild
+- 자식 요소를 그대로 렌더링하고, 해당 요소를 관찰 대상으로 설정합니다.
+- 자식 요소가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다.
+- 이때 자식 요소는 단일 요소만 허용됩니다.
+```tsx title="typescript"
+import { InView } from '@modern-kit/react';
+
+const Example = () => {
+ const ref = useRef(null);
+
+ const handleIntersectStart = () => {
+ /* action */
+ }
+
+ const handleIntersectEnd = () => {
+ /* action */
+ }
+
+ return (
+
- Box1
+ onIntersectEnd={handleIntersectEnd}
+ >
+ List Item1
+ List Item2
;
);
@@ -77,32 +107,30 @@ export const Example = () => {
console.log('action onIntersectStart(1)')}
onIntersectEnd={() => console.log('action onIntersectEnd(1)')}
- calledOnce
- >
-
-
Box1
-
브라우저 개발자 도구의 콘솔을 확인해주세요.
-
onIntersectStart가 최초 1회만 호출됩니다.
-
calledOnce: true
-
+ }}
+ calledOnce
+ >
+ Box1
+ 브라우저 개발자 도구의 콘솔을 확인해주세요.
+ calledOnce: true
+ as: div
console.log('action onIntersectStart(2)')}
onIntersectEnd={() => console.log('action onIntersectEnd(2)')}
- >
-
-
Box2
-
브라우저 개발자 도구의 콘솔을 확인해주세요.
-
onIntersectStart, onIntersectEnd 함수가 여러 번 호출됩니다.
-
calledOnce: false
-
+ }}
+ >
+ Box2
+ 브라우저 개발자 도구의 콘솔을 확인해주세요.
+ calledOnce: false
+ as: ul
diff --git a/packages/react/src/utils/test/mockFile.ts b/packages/react/src/_internal/test/mockFile.ts
similarity index 100%
rename from packages/react/src/utils/test/mockFile.ts
rename to packages/react/src/_internal/test/mockFile.ts
diff --git a/packages/react/src/utils/test/mockIntersectionObserver.ts b/packages/react/src/_internal/test/mockIntersectionObserver.ts
similarity index 100%
rename from packages/react/src/utils/test/mockIntersectionObserver.ts
rename to packages/react/src/_internal/test/mockIntersectionObserver.ts
diff --git a/packages/react/src/utils/test/renderSetup.ts b/packages/react/src/_internal/test/renderSetup.ts
similarity index 100%
rename from packages/react/src/utils/test/renderSetup.ts
rename to packages/react/src/_internal/test/renderSetup.ts
diff --git a/packages/react/src/components/ClientGate/ClientGate.spec.tsx b/packages/react/src/components/ClientGate/ClientGate.spec.tsx
index d9518a4ad..6a3a00f4a 100644
--- a/packages/react/src/components/ClientGate/ClientGate.spec.tsx
+++ b/packages/react/src/components/ClientGate/ClientGate.spec.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { renderToString } from 'react-dom/server';
import { ClientGate } from '.';
diff --git a/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx b/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx
index a4bc21385..c072b66d5 100644
--- a/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx
+++ b/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { DebounceWrapper } from '.';
import { ChangeEvent, useState } from 'react';
import { act, screen } from '@testing-library/react';
diff --git a/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx b/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx
index 8778e8f43..8ad2bc0a9 100644
--- a/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx
+++ b/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx
@@ -5,8 +5,8 @@ import {
mockIntersecting,
mockIntersectionObserverCleanup,
mockIntersectionObserverSetup,
-} from '../../utils/test/mockIntersectionObserver';
-import { renderSetup } from '../../utils/test/renderSetup';
+} from '../../_internal/test/mockIntersectionObserver';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { Mock } from 'vitest';
beforeEach(() => {
diff --git a/packages/react/src/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx
index 0a9c4d704..d4dd664fb 100644
--- a/packages/react/src/components/InView/InView.spec.tsx
+++ b/packages/react/src/components/InView/InView.spec.tsx
@@ -5,8 +5,9 @@ import {
mockIntersecting,
mockIntersectionObserverCleanup,
mockIntersectionObserverSetup,
-} from '../../utils/test/mockIntersectionObserver';
-import { renderSetup } from '../../utils/test/renderSetup';
+} from '../../_internal/test/mockIntersectionObserver';
+import { renderSetup } from '../../_internal/test/renderSetup';
+import { ElementType } from 'react';
beforeEach(() => {
mockIntersectionObserverSetup();
@@ -19,16 +20,19 @@ afterEach(() => {
interface TestComponentProps {
onIntersectStart: () => void;
onIntersectEnd: () => void;
+ as?: ElementType;
calledOnce?: boolean;
}
const TestComponent = ({
onIntersectStart,
onIntersectEnd,
+ as,
calledOnce,
}: TestComponentProps) => {
return (
@@ -41,7 +45,7 @@ describe('InView', () => {
const intersectStartMock = vi.fn();
const intersectEndMock = vi.fn();
- it('InView 컴포넌트가 viewport에 노출되거나 숨겨질 때 onIntersect 콜백 함수를 호출해야 합니다.', async () => {
+ it('InView 컴포넌트가 viewport에 노출되거나 숨겨질 때 onIntersect 콜백 함수를 호출해야 합니다. 기본적으로 div 요소로 렌더링되어야 합니다.', async () => {
renderSetup(
{
/>
);
- const box = screen.getByText('box');
+ const boxWrapper = screen.getByText('box').parentElement as HTMLElement;
+ expect(boxWrapper.tagName).toBe('DIV');
expect(intersectStartMock).toBeCalledTimes(0);
expect(intersectEndMock).toBeCalledTimes(0);
- await waitFor(() => mockIntersecting({ type: 'view', element: box }));
+ await waitFor(() =>
+ mockIntersecting({ type: 'view', element: boxWrapper })
+ );
+ expect(intersectStartMock).toBeCalledTimes(1);
+
+ await waitFor(() =>
+ mockIntersecting({ type: 'hide', element: boxWrapper })
+ );
+ expect(intersectEndMock).toBeCalledTimes(1);
+ });
+
+ it('as props를 통해 특정 요소로 렌더링할 수 있습니다.', async () => {
+ renderSetup(
+
+ );
+
+ const ulWrapper = screen.getByText('box').parentElement as HTMLElement;
+ expect(ulWrapper.tagName).toBe('UL');
+
+ await waitFor(() => mockIntersecting({ type: 'view', element: ulWrapper }));
expect(intersectStartMock).toBeCalledTimes(1);
- await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
+ await waitFor(() => mockIntersecting({ type: 'hide', element: ulWrapper }));
expect(intersectEndMock).toBeCalledTimes(1);
});
@@ -70,17 +98,27 @@ describe('InView', () => {
/>
);
- const box = screen.getByText('box');
+ const boxWrapper = screen.getByText('box').parentElement as HTMLElement;
- await waitFor(() => mockIntersecting({ type: 'view', element: box }));
+ await waitFor(() =>
+ mockIntersecting({ type: 'view', element: boxWrapper })
+ );
expect(intersectStartMock).toBeCalledTimes(1);
- await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
+ await waitFor(() =>
+ mockIntersecting({ type: 'hide', element: boxWrapper })
+ );
expect(intersectEndMock).toBeCalledTimes(1);
- await waitFor(() => mockIntersecting({ type: 'view', element: box }));
- await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box }));
+ await waitFor(() =>
+ mockIntersecting({ type: 'view', element: boxWrapper })
+ );
+ await waitFor(() =>
+ mockIntersecting({ type: 'hide', element: boxWrapper })
+ );
+ await waitFor(() =>
+ mockIntersecting({ type: 'view', element: boxWrapper })
+ );
expect(intersectStartMock).toBeCalledTimes(1);
expect(intersectEndMock).toBeCalledTimes(1);
diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx
index 929458620..300c023d3 100644
--- a/packages/react/src/components/InView/index.tsx
+++ b/packages/react/src/components/InView/index.tsx
@@ -1,22 +1,27 @@
-import { Slot } from '../Slot';
+import React from 'react';
import {
useIntersectionObserver,
UseIntersectionObserverProps,
} from '../../hooks/useIntersectionObserver';
+import { polymorphicForwardRef } from '../../utils/polymorphicForwardRef';
+import { useMergeRefs } from '../../hooks/useMergeRefs';
interface InViewProps extends UseIntersectionObserverProps {
- children: JSX.Element;
+ children: React.ReactNode;
}
/**
* @description `InView`는 `IntersectionObserver`를 선언적으로 활용 할 수 있는 컴포넌트입니다.
*
+ * 관찰 대상이 `viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다.
+ *
* `@modern-kit/react`의 `useIntersectionObserver` 훅을 사용하여 구현되었습니다.
*
* @see https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver
*
* @param {InViewProps} props - 컴포넌트에 전달되는 속성들입니다.
- * @param {JSX.Element} props.children - 관찰할 자식 요소입니다.
+ * @param {React.ReactNode} props.children - 관찰할 자식 요소입니다.
+ * @param {boolean} props.asChild - 자식 요소를 그대로 렌더링할지 여부를 나타냅니다. `true`일 경우 자식 요소가 그대로 렌더링되며, 자식 요소가 관찰 대상이됩니다.
* @param {(entry: IntersectionObserverEntry) => void} props.onIntersectStart - 타겟 요소가 viewport 내에 들어갈 때 호출되는 콜백 함수입니다.
* @param {(entry: IntersectionObserverEntry) => void} props.onIntersectEnd - 타겟 요소가 viewport에서 나갈 때 호출되는 콜백 함수입니다.
* @param {number | number[]} props.threshold - 관찰을 시작할 viewport의 가시성 비율입니다.
@@ -24,9 +29,36 @@ interface InViewProps extends UseIntersectionObserverProps {
* @param {string} props.rootMargin - 루트 요소에 대한 마진을 지정합니다. 이는 뷰포트 또는 루트 요소의 경계를 확장하거나 축소하는데 사용됩니다.
* @param {boolean} props.enabled - Observer를 활성화할지 여부를 나타냅니다. `false`일 경우 Observer가 작동하지 않습니다.
* @param {boolean} props.calledOnce - 요소가 교차할 때 콜백을 `한 번`만 호출할지 여부를 나타냅니다.
+ *
+ * @returns {JSX.Element}
+ *
+ * @example
+ * ```tsx
+ * // 기본적으로 div로 감싸지며, 해당 div를 관찰 대상으로 설정합니다.
+ * // 해당 div가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백 함수를 호출합니다.
+ *
+ * Content1
+ *
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // as 속성을 통해 특정 요소로 렌더링할 수 있습니다.
+ *
+ * List Item1
+ * List Item2
+ *
+ * ```
*/
-export const InView = ({ children, ...props }: InViewProps) => {
- const { ref: intersectionObserverRef } = useIntersectionObserver(props);
+export const InView = polymorphicForwardRef<'div', InViewProps>(
+ ({ children, as = 'div', ...props }, ref) => {
+ const Wrapper = as ?? 'div';
+ const { ref: intersectionObserverRef } = useIntersectionObserver(props);
- return {children};
-};
+ return (
+
+ {children}
+
+ );
+ }
+);
diff --git a/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx b/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx
index a0fe0965f..376fa7484 100644
--- a/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx
+++ b/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx
@@ -5,8 +5,8 @@ import {
mockIntersecting,
mockIntersectionObserverCleanup,
mockIntersectionObserverSetup,
-} from '../../utils/test/mockIntersectionObserver';
-import { renderSetup } from '../../utils/test/renderSetup';
+} from '../../_internal/test/mockIntersectionObserver';
+import { renderSetup } from '../../_internal/test/renderSetup';
beforeEach(() => {
mockIntersectionObserverSetup();
diff --git a/packages/react/src/components/Iterator/Iterator.spec.tsx b/packages/react/src/components/Iterator/Iterator.spec.tsx
index d70d7be84..afe034995 100644
--- a/packages/react/src/components/Iterator/Iterator.spec.tsx
+++ b/packages/react/src/components/Iterator/Iterator.spec.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { screen } from '@testing-library/react';
import { Iterator } from '.';
diff --git a/packages/react/src/components/LazyImage/LazyImage.spec.tsx b/packages/react/src/components/LazyImage/LazyImage.spec.tsx
index 568db5480..f77987690 100644
--- a/packages/react/src/components/LazyImage/LazyImage.spec.tsx
+++ b/packages/react/src/components/LazyImage/LazyImage.spec.tsx
@@ -5,8 +5,8 @@ import {
mockIntersecting,
mockIntersectionObserverCleanup,
mockIntersectionObserverSetup,
-} from '../../utils/test/mockIntersectionObserver';
-import { renderSetup } from '../../utils/test/renderSetup';
+} from '../../_internal/test/mockIntersectionObserver';
+import { renderSetup } from '../../_internal/test/renderSetup';
beforeEach(() => {
mockIntersectionObserverSetup();
diff --git a/packages/react/src/components/Mounted/Mounted.spec.tsx b/packages/react/src/components/Mounted/Mounted.spec.tsx
index d44831f94..13755ce4d 100644
--- a/packages/react/src/components/Mounted/Mounted.spec.tsx
+++ b/packages/react/src/components/Mounted/Mounted.spec.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { renderToString } from 'react-dom/server';
import { Mounted } from '.';
diff --git a/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx b/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx
index 76d39eac0..ee3e3fa53 100644
--- a/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx
+++ b/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { waitFor, screen } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { OutsideClick } from './index';
describe('OutsideClick', () => {
diff --git a/packages/react/src/components/Portal/Portal.spec.tsx b/packages/react/src/components/Portal/Portal.spec.tsx
index 34f9a7dde..fb9c61ac8 100644
--- a/packages/react/src/components/Portal/Portal.spec.tsx
+++ b/packages/react/src/components/Portal/Portal.spec.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { Portal } from '.';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { Nullable } from '@modern-kit/types';
const DefaultTestComponent = () => {
diff --git a/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx b/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx
index fe63b665f..2674f5026 100644
--- a/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx
+++ b/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { screen } from '@testing-library/react';
import { SeparatedIterator } from '.';
diff --git a/packages/react/src/components/Slot/Slot.spec.tsx b/packages/react/src/components/Slot/Slot.spec.tsx
index 35eb754aa..44be1ac13 100644
--- a/packages/react/src/components/Slot/Slot.spec.tsx
+++ b/packages/react/src/components/Slot/Slot.spec.tsx
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Slot, Slottable } from '.';
import { ComponentProps, PropsWithChildren, ReactElement } from 'react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { screen } from '@testing-library/dom';
const TestButton = ({
diff --git a/packages/react/src/components/SwitchCase/SwitchCase.test.tsx b/packages/react/src/components/SwitchCase/SwitchCase.test.tsx
index 23f91dcac..5cf5b8d98 100644
--- a/packages/react/src/components/SwitchCase/SwitchCase.test.tsx
+++ b/packages/react/src/components/SwitchCase/SwitchCase.test.tsx
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { SwitchCase } from '.';
describe('SwitchCase', () => {
diff --git a/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx b/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx
index 3251a7b8b..9c36835b8 100644
--- a/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx
+++ b/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, renderHook, act } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { useBlockPromiseMultipleClick } from '.';
import { delay } from '@modern-kit/utils';
diff --git a/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx b/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx
index ae2cd3afb..9aefc59b5 100644
--- a/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx
+++ b/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { useDebouncedInputValue } from '.';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { delay } from '@modern-kit/utils';
const DELAY = 200;
diff --git a/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx b/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx
index ecb0301da..01c13fb5d 100644
--- a/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx
+++ b/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { useDebouncedState } from '.';
import { delay } from '@modern-kit/utils';
diff --git a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts
index 0e911067e..cacc02430 100644
--- a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts
+++ b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts
@@ -5,7 +5,7 @@ import {
MockFileReaderForcedCallOnError,
MockFileReaderThrowError,
mockFileList,
-} from '../../utils/test/mockFile';
+} from '../../_internal/test/mockFile';
const getSuccessFileContent = (file: File) => {
return {
diff --git a/packages/react/src/hooks/useFocus/useFocus.spec.tsx b/packages/react/src/hooks/useFocus/useFocus.spec.tsx
index a051e4b7a..e1e1d3beb 100644
--- a/packages/react/src/hooks/useFocus/useFocus.spec.tsx
+++ b/packages/react/src/hooks/useFocus/useFocus.spec.tsx
@@ -1,6 +1,6 @@
import { describe, it, expect, Mock, vi } from 'vitest';
import { useFocus } from '.';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { screen } from '@testing-library/react';
const TestComponent = ({
diff --git a/packages/react/src/hooks/useHover/useHover.spec.tsx b/packages/react/src/hooks/useHover/useHover.spec.tsx
index 798fdd0ae..a220e6e46 100644
--- a/packages/react/src/hooks/useHover/useHover.spec.tsx
+++ b/packages/react/src/hooks/useHover/useHover.spec.tsx
@@ -1,6 +1,6 @@
import { describe, it, expect, Mock, vi } from 'vitest';
import { useHover } from '.';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { screen, waitFor } from '@testing-library/react';
describe('useHover', () => {
diff --git a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx
index d59c18f1a..767d36c3a 100644
--- a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx
+++ b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx
@@ -3,10 +3,10 @@ import {
mockIntersecting,
mockIntersectionObserverCleanup,
mockIntersectionObserverSetup,
-} from '../../utils/test/mockIntersectionObserver';
+} from '../../_internal/test/mockIntersectionObserver';
import { useIntersectionObserver } from '.';
import { waitFor, screen } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
beforeEach(() => {
mockIntersectionObserverSetup();
diff --git a/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx b/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx
index 0f1ee439a..a00e7239b 100644
--- a/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx
+++ b/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { useKeyDown } from '.';
import { screen } from '@testing-library/react';
diff --git a/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx b/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx
index af7a0fedd..23690a75c 100644
--- a/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx
+++ b/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { useRef, useState } from 'react';
import { useMergeRefs } from '.';
import { screen, waitFor } from '@testing-library/react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { Nullable } from '@modern-kit/types';
const TestComponent = () => {
diff --git a/packages/react/src/hooks/useMouse/useMouse.spec.tsx b/packages/react/src/hooks/useMouse/useMouse.spec.tsx
index 828ec4977..64631b325 100644
--- a/packages/react/src/hooks/useMouse/useMouse.spec.tsx
+++ b/packages/react/src/hooks/useMouse/useMouse.spec.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
import { fireEvent, screen } from '@testing-library/react';
import { useMouse } from '.';
diff --git a/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx b/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx
index d9337dc59..1823cbfcd 100644
--- a/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx
+++ b/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { useOutsidePointerDown } from '.';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
const TestComponent = ({ onAction }: { onAction: () => void }) => {
const targetRef = useOutsidePointerDown(onAction);
diff --git a/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx b/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx
index 19219a564..0f06a9281 100644
--- a/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx
+++ b/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act, renderHook, screen } from '@testing-library/react';
import { useTimeout } from '.';
import { useState } from 'react';
-import { renderSetup } from '../../utils/test/renderSetup';
+import { renderSetup } from '../../_internal/test/renderSetup';
const delayTime = 1000;
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index f76fd6f16..c55977d1b 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,2 +1,3 @@
export * from './components';
export * from './hooks';
+export * from './utils';
diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts
new file mode 100644
index 000000000..7a6be9ad0
--- /dev/null
+++ b/packages/react/src/utils/index.ts
@@ -0,0 +1 @@
+export * from './polymorphicForwardRef';
diff --git a/packages/react/src/utils/polymorphicForwardRef.tsx b/packages/react/src/utils/polymorphicForwardRef.tsx
new file mode 100644
index 000000000..22269bdb7
--- /dev/null
+++ b/packages/react/src/utils/polymorphicForwardRef.tsx
@@ -0,0 +1,177 @@
+import {
+ forwardRef,
+ Fragment,
+ type ComponentProps,
+ type ComponentPropsWithRef,
+ type ElementType,
+ type ForwardRefExoticComponent,
+ type ForwardRefRenderFunction,
+ type ReactElement,
+} from 'react';
+
+/**
+ * @description 유니온 타입에서 각각의 타입에 대해 Omit을 적용하는 타입입니다.
+ * `조건부 타입`을 사용하여 분배법칙처럼 동작합니다.
+ *
+ * @template T - 분배 대상이 되는 유니온 타입
+ * @template K - 제거할 프로퍼티 키
+ *
+ * @example
+ * type Union = { a: string } | { b: number }
+ * type Result = DistributiveOmit
+ *
+ * // 동작 원리와 순서
+ * // 1. Result = DistributiveOmit
+ * // 2. Result = Omit<{ a: string }, 'a'> | Omit<{ b: number }, 'a'>
+ * // 3. Result = {} | { b: number }
+ */
+type DistributiveOmit = T extends any
+ ? Omit
+ : never;
+
+/**
+ * @description 두 타입을 병합하는 타입입니다.
+ *
+ * @template A - 첫 번째 타입
+ * @template B - 두 번째 타입
+ *
+ * @example
+ * type A = { a: string, b: number }
+ * type B = { b: string, c: boolean }
+ * type Result = Merge
+ *
+ * // 동작 원리와 순서
+ * // 1. Result = Merge
+ * // 2. Result = Omit & B
+ * // 3. Result = { a: string } & B
+ * // 4. Result = { a: string, b: string, c: boolean }
+ */
+type Merge = Omit & B;
+
+/**
+ * @description 유니온 타입의 각 구성 요소에 대해 B 타입과의 병합을 수행하는 타입입니다.
+ * 각 유니온 멤버에서 B의 키들을 제거한 후, B 타입과 병합합니다.
+ *
+ * @template A - 병합의 대상이 되는 유니온 타입
+ * @template B - 각 유니온 멤버와 병합될 타입
+ *
+ * @example
+ * type A = { a: string, c: boolean } | { b: number }
+ * type B = { c: boolean }
+ * type Result = DistributiveMerge
+ *
+ * // 동작 원리와 순서
+ * // 1. Result = (Omit<{ a: string, c: boolean }, "c"> | Omit<{ b: number }, 'c'>) & B;
+ * // 2. Result = { a: string } & B | { b: number } & B;
+ * // 3. Result = { a: string, c: boolean } | { b: number, c: boolean }
+ */
+type DistributiveMerge = DistributiveOmit & B;
+
+/**
+ * @description 다형성 컴포넌트의 props 타입을 정의하는 타입입니다. 이때, `as` 프로퍼티를 포함합니다.
+ *
+ * @template Component - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다.
+ * @template PermanentProps - 컴포넌트의 고정 props 타입입니다.
+ * @template ComponentProps - 지정된 요소 타입에 기본적으로 제공하는 props 타입입니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다.
+ *
+ * @example
+ * interface ButtonProps {
+ * variant: 'primary' | 'secondary';
+ * size: 'sm' | 'md' | 'lg';
+ * }
+ *
+ * // button 요소로 렌더링될 때의 타입
+ * type HtmlButtonProps = AsProps<'button', ButtonProps, ComponentProps<'button'>>
+ * // HtmlButtonProps = {
+ * // variant: 'primary' | 'secondary';
+ * // size: 'sm' | 'md' | 'lg';
+ * // as?: 'button';
+ * // ... 기타 button의 HTML 속성들
+ * // }
+ */
+type AsProps<
+ Component extends ElementType,
+ PermanentProps extends Record,
+ ComponentProps extends Record
+> = DistributiveMerge;
+
+/**
+ * @description ref를 포함한 다형성 컴포넌트의 함수 시그니처를 정의하는 타입입니다.
+ * 하나의 컴포넌트가 여러 HTML 요소로 렌더링될 수 있도록 하며, 각 요소에 맞는 props와 ref를 자동으로 처리합니다.
+ *
+ * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다.
+ * @template Props - 컴포넌트의 커스텀 props 타입입니다.
+ * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType)
+ */
+type PolymorphicWithRef<
+ Default extends OnlyAs,
+ Props extends Record,
+ OnlyAs extends ElementType = ElementType
+> = (
+ props: AsProps<
+ T,
+ Props,
+ T extends ElementType
+ ? ComponentPropsWithRef
+ : ComponentProps
+ >
+) => ReactElement | null;
+
+/**
+ * @description React.forwardRef를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다.
+ * ForwardRefExoticComponent와 PolymorphicWithRef를 결합합니다.
+ *
+ * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다.
+ * @template Props - 컴포넌트의 커스텀 props 타입입니다.
+ * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType)
+ */
+type PolyForwardComponent<
+ Default extends OnlyAs,
+ Props extends Record,
+ OnlyAs extends ElementType = ElementType
+> = Merge<
+ ForwardRefExoticComponent<
+ Merge<
+ Default extends ElementType
+ ? ComponentPropsWithRef
+ : ComponentProps,
+ Props & { as?: Default }
+ >
+ >,
+ PolymorphicWithRef
+>;
+
+/**
+ * @description React.forwardRef를 위한 다형성 타입 래퍼입니다.
+ * 컴포넌트에 다형성과 ref 전달 기능을 모두 부여합니다.
+ *
+ * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다.
+ * @template Props - 컴포넌트의 커스텀 props 타입입니다.
+ * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType)
+ *
+ * @returns 다형성과 ref 전달이 가능한 새로운 컴포넌트 타입을 반환합니다.
+ */
+type PolyRefFunction = <
+ Default extends OnlyAs,
+ Props extends Record,
+ OnlyAs extends ElementType = ElementType
+>(
+ Component: ForwardRefRenderFunction
+) => PolyForwardComponent;
+
+/**
+ * @description React.forwardRef를 다형성 컴포넌트를 위한 타입으로 캐스팅하는 유틸리티입니다.
+ * 기존의 forwardRef를 PolyRefFunction 타입으로 변환하여 다형성과 ref 전달을 모두 지원하는 컴포넌트를 생성할 수 있게 합니다.
+ *
+ * @example
+ * interface ButtonProps {
+ * variant: 'primary' | 'secondary';
+ * size: 'sm' | 'md' | 'lg';
+ * }
+ *
+ * const Button = polymorphicForwardRef<'button', ButtonProps>((props, ref) => {
+ * const Component = props.as ?? 'button';
+ * return ;
+ * });
+ */
+export const polymorphicForwardRef = forwardRef as PolyRefFunction;