diff --git a/src/components/Heading/Heading.tsx b/src/components/Heading/Heading.tsx
index b7b3a94..f8f28ac 100644
--- a/src/components/Heading/Heading.tsx
+++ b/src/components/Heading/Heading.tsx
@@ -13,7 +13,7 @@ export interface HeadingProps extends HTMLAttributes
{
size?: FontSize;
/** 굵기 */
weight?: FontWeight;
- /** 명암비 */
+ /** 명암비 낮출지 */
muted?: boolean;
}
diff --git a/src/components/Text/Text.stories.tsx b/src/components/Text/Text.stories.tsx
new file mode 100644
index 0000000..5f19d4e
--- /dev/null
+++ b/src/components/Text/Text.stories.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { vstack } from "../../../styled-system/patterns";
+import { Text } from "./Text";
+
+export default {
+ component: Text,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ children: "본문",
+ },
+} satisfies Meta;
+
+export const Basic: StoryObj = {};
+
+export const Tones: StoryObj = {
+ render: (args) => {
+ return (
+
+
+ 중립 색조
+
+
+ 강조 색조
+
+
+ 위험 색조
+
+
+ 경고 색조
+
+
+ );
+ },
+ argTypes: {
+ children: {
+ control: false,
+ },
+ tone: {
+ control: false,
+ },
+ },
+};
+
+export const Contrasts: StoryObj = {
+ render: (args) => {
+ return (
+
+
+ 낮은 명암비
+
+ 높은 명암비
+
+ );
+ },
+ argTypes: {
+ children: {
+ control: false,
+ },
+ muted: {
+ control: false,
+ },
+ },
+};
diff --git a/src/components/Text/Text.test.tsx b/src/components/Text/Text.test.tsx
new file mode 100644
index 0000000..253cf9e
--- /dev/null
+++ b/src/components/Text/Text.test.tsx
@@ -0,0 +1,54 @@
+import { faker } from "@faker-js/faker";
+import { composeStories } from "@storybook/react";
+import { render, screen } from "@testing-library/react";
+import { expect, test } from "vitest";
+import { fontSizes, fontWeights } from "../../tokens/typography";
+import * as stories from "./Text.stories";
+
+const { Basic, Tones, Contrasts } = composeStories(stories);
+
+test("renders the heading with the correct text content", () => {
+ render(테스트);
+
+ expect(screen.getByText("테스트"));
+});
+
+test("applies the correct font weight class based on the weight prop", () => {
+ const weight = faker.helpers.arrayElement(
+ Object.keys(fontWeights)
+ ) as keyof typeof fontWeights;
+
+ render();
+
+ expect(screen.getByText("본문")).toHaveClass(`fw_${weight}`);
+});
+
+test("applies the correct font size class based on the size prop", () => {
+ const size = faker.helpers.arrayElement(
+ Object.keys(fontSizes)
+ ) as keyof typeof fontSizes;
+
+ render();
+
+ expect(screen.getByText("본문")).toHaveClass(`fs_${size}`);
+});
+
+test("applies the correct color based on the tone", () => {
+ render();
+
+ expect(screen.getByText("중립 색조")).toHaveClass("c_text");
+
+ expect(screen.getByText("강조 색조")).toHaveClass("c_text.accent");
+
+ expect(screen.getByText("위험 색조")).toHaveClass("c_text.danger");
+
+ expect(screen.getByText("경고 색조")).toHaveClass("c_text.warning");
+});
+
+test("applies the correct color for low and high contrast", () => {
+ render();
+
+ expect(screen.getByText("낮은 명암비")).toHaveClass("c_text.muted");
+
+ expect(screen.getByText("높은 명암비")).toHaveClass("c_text");
+});
diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx
new file mode 100644
index 0000000..84112c4
--- /dev/null
+++ b/src/components/Text/Text.tsx
@@ -0,0 +1,93 @@
+import type { ReactNode, HTMLAttributes } from "react";
+import { css, cva } from "../../../styled-system/css";
+import type { Tone } from "../../tokens/colors";
+import type { FontSize, FontWeight } from "../../tokens/typography";
+
+export interface TextProps extends HTMLAttributes {
+ /** 텍스트 */
+ children: ReactNode;
+ /** HTML 태그 */
+ as?: "span" | "div" | "p" | "strong" | "em" | "small";
+ /** 색조 */
+ tone?: Tone;
+ /** 크기 */
+ size?: FontSize;
+ /** 굵기 */
+ weight?: FontWeight;
+ /** 명암비 낮출지 */
+ muted?: boolean;
+}
+
+/**
+ * - `as` 속성으로 어떤 HTML 태그를 사용할지 지정할 수 있습니다.
+ * - `muted` 속성을 주시면 글자색이 옅어집니다. 명암비가 낮아지므로 접근성 측면에서 주의해서 사용하세요.
+ */
+export const Text = ({
+ children,
+ as: Tag = "span",
+ tone = "neutral",
+ size,
+ weight,
+ muted = false,
+ ...rest
+}: TextProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const styles = cva({
+ compoundVariants: [
+ {
+ muted: false,
+ tone: "neutral",
+ css: { color: "text" },
+ },
+ {
+ muted: false,
+ tone: "accent",
+ css: { color: "text.accent" },
+ },
+ {
+ muted: false,
+ tone: "danger",
+ css: { color: "text.danger" },
+ },
+ {
+ muted: false,
+ tone: "warning",
+ css: { color: "text.warning" },
+ },
+ {
+ muted: true,
+ tone: "neutral",
+ css: { color: "text.muted" },
+ },
+ {
+ muted: true,
+ tone: "accent",
+ css: { color: "text.muted.accent" },
+ },
+ {
+ muted: true,
+ tone: "danger",
+ css: { color: "text.muted.danger" },
+ },
+ {
+ muted: true,
+ tone: "warning",
+ css: { color: "text.muted.warning" },
+ },
+ ],
+});
diff --git a/src/components/Text/index.tsx b/src/components/Text/index.tsx
new file mode 100644
index 0000000..7afe56f
--- /dev/null
+++ b/src/components/Text/index.tsx
@@ -0,0 +1 @@
+export { Text } from "./Text";
diff --git a/src/tokens/colors.ts b/src/tokens/colors.ts
index 2aff6eb..6ea04a2 100644
--- a/src/tokens/colors.ts
+++ b/src/tokens/colors.ts
@@ -1,5 +1,7 @@
import type { Tokens, SemanticTokens } from "@pandacss/types";
+export type Tone = "neutral" | "accent" | "danger" | "warning";
+
export const semanticColors: SemanticTokens["colors"] = {
current: { value: "currentColor" },
transparent: { value: "rgb(0 0 0 / 0)" },