diff --git a/README.md b/README.md
index a0eb988..165b0ea 100644
--- a/README.md
+++ b/README.md
@@ -204,6 +204,20 @@ You can determine the initial on/off state of the toggle through `defaultValue`.
`toggle` : Reverses the current state of the switch.
+### useFocus
+
+Executes the provided callback function when a DOM element becomes visible on the screen.
+
+#### Parameters
+
+`onFocusCallback`: A function to execute when the element comes into focus.
+`threshold`: Determines the visibility threshold for the element. A value of 1 means the element must be fully visible within the viewport.
+`rootMargin`: Adjusts the viewport’s dimensions using the format "top right bottom left", with units explicitly specified.
+
+#### Return Value
+
+`ref`: A ref object to assign to the DOM element that should trigger the focus callback.
+
## Animation
The animation of this package is based on ClassName by default.
diff --git a/src/index.ts b/src/index.ts
index b0e0793..a88c174 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,7 @@ import useDebounce from './useDebounce/useDebounce';
import useLocalStorage from './useLocalStorage/useLocalStorage';
import useDisclosure from './useDisclosure/useDisclosure';
import useModal from './useModal/useModal';
+import useFocus from './useFocus/useFocus';
export {
useInput,
@@ -26,4 +27,5 @@ export {
useLocalStorage,
useDisclosure,
useModal,
+ useFocus,
};
diff --git a/src/stories/useFocus/Docs.mdx b/src/stories/useFocus/Docs.mdx
new file mode 100644
index 0000000..6af5829
--- /dev/null
+++ b/src/stories/useFocus/Docs.mdx
@@ -0,0 +1,22 @@
+import { Canvas, Meta, Description } from '@storybook/blocks';
+import * as Focus from './Focus.stories';
+
+
+
+# useFocus
+
+DOM Element가 화면에 노출되었을때, callback으로 전달된 함수를 실행합니다.
+
+## 함수인자
+
+`onFocusCallback` : 요소가 focus되었을때, 수행할 함수입니다.
+
+`threshold` : 요소가 화면에 보일때의 기준을 결정합니다. 1이면 전체요소가 모두 화면에 들어왔을때 입니다.
+
+`rootMargin` : 화면의 너비, 높이를 조정할 수 있습니다. "top right bottom left" 형태로 기재하며 반드시 단위를 명시해야합니다.
+
+## 반환값
+
+`ref`: focus될 DOM요소에 할당해줄 ref 객체입니다.
+
+
diff --git a/src/stories/useFocus/Focus.css.ts b/src/stories/useFocus/Focus.css.ts
new file mode 100644
index 0000000..12d483b
--- /dev/null
+++ b/src/stories/useFocus/Focus.css.ts
@@ -0,0 +1,17 @@
+import { style } from '@vanilla-extract/css';
+
+export const backgroundDiv = style({
+ height: 100,
+});
+
+export const scrollDiv = style({
+ position: 'absolute',
+ backgroundColor: 'black',
+ color: 'white',
+ borderRadius: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ display: 'flex',
+ padding: 16,
+ top: 200,
+});
diff --git a/src/stories/useFocus/Focus.stories.ts b/src/stories/useFocus/Focus.stories.ts
new file mode 100644
index 0000000..f4633ec
--- /dev/null
+++ b/src/stories/useFocus/Focus.stories.ts
@@ -0,0 +1,21 @@
+import { Meta, StoryObj } from '@storybook/react';
+import Focus from './Focus';
+
+const meta = {
+ title: 'hooks/useFocus',
+ component: Focus,
+ parameters: {
+ layout: 'centered',
+ docs: {
+ canvas: {},
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const defaultStory: Story = {
+ args: {},
+};
diff --git a/src/stories/useFocus/Focus.tsx b/src/stories/useFocus/Focus.tsx
new file mode 100644
index 0000000..d696b19
--- /dev/null
+++ b/src/stories/useFocus/Focus.tsx
@@ -0,0 +1,16 @@
+import React, { useState } from 'react';
+import useFocus from '@/useFocus/useFocus';
+import { backgroundDiv, scrollDiv } from './Focus.css';
+
+export default function Focus() {
+ const [message, setMessage] = useState('NOT FOCUS');
+ const ref = useFocus(() => setMessage('FOCUS!'), 1, '-10px');
+
+ return (
+
+ );
+}
diff --git a/src/useFocus/useFocus.test.tsx b/src/useFocus/useFocus.test.tsx
new file mode 100644
index 0000000..48f2892
--- /dev/null
+++ b/src/useFocus/useFocus.test.tsx
@@ -0,0 +1,57 @@
+import { fireEvent, render } from '@testing-library/react';
+import useFocus from './useFocus';
+import React from 'react';
+
+interface Entrie {
+ target: Element;
+ isIntersecting: boolean;
+}
+
+const mockIntersectionObserver = class {
+ entries: Entrie[];
+ constructor(callback) {
+ this.entries = [];
+ window.addEventListener('scroll', () => {
+ if (window.scrollY > 50) {
+ this.entries.map((entry) => {
+ entry.isIntersecting = this.isInViewPort();
+ });
+ }
+ callback(this.entries, this);
+ });
+ }
+
+ isInViewPort() {
+ return true;
+ }
+
+ observe(target: Element) {
+ this.entries.push({ isIntersecting: false, target });
+ }
+
+ unobserve(target) {
+ this.entries = this.entries.filter((ob) => ob.target !== target);
+ }
+
+ disconnect() {
+ this.entries = [];
+ }
+};
+
+describe('useFocus 기능 테스트', () => {
+ beforeEach(() => {
+ // eslint-disable-next-line
+ global.IntersectionObserver = mockIntersectionObserver as any;
+ });
+
+ it('focus되었을때 onFocusCallback을 실행시킬 수 있다.', async () => {
+ const mock = jest.fn();
+ const Test = () => {
+ const ref = useFocus(mock);
+ return Test
;
+ };
+ render();
+ fireEvent.scroll(window, { target: { scrollY: 100 } });
+ expect(mock).toHaveBeenCalled();
+ });
+});
diff --git a/src/useFocus/useFocus.ts b/src/useFocus/useFocus.ts
new file mode 100644
index 0000000..4f460fb
--- /dev/null
+++ b/src/useFocus/useFocus.ts
@@ -0,0 +1,36 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+export default function useFocus(
+ onFocusCallback: (() => void) | (() => Promise),
+ threshold = 0.1,
+ rootMargin?: string,
+) {
+ const elementRef = useRef(null);
+
+ const handleScroll: IntersectionObserverCallback = useCallback(([entry]) => {
+ const { current } = elementRef;
+ if (current) {
+ if (entry.isIntersecting) {
+ onFocusCallback();
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ let observer: IntersectionObserver;
+ const { current } = elementRef;
+
+ if (current) {
+ observer = new IntersectionObserver(handleScroll, {
+ threshold,
+ rootMargin: rootMargin || '0px 0px 0px 0px',
+ });
+
+ observer.observe(current);
+
+ return () => observer && observer.disconnect();
+ }
+ }, [handleScroll]);
+
+ return elementRef;
+}