diff --git a/src/app/globals.css b/src/app/globals.css
index ef0cae6a..d2c0510a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2,4 +2,5 @@
@import '../styles/colors.css';
@import '../styles/typography.css';
+@import '../styles/animations.css';
@import '../styles/base.css';
diff --git a/src/domains/home/components/bookmark-container/bookmark-container.tsx b/src/domains/home/components/bookmark-container/bookmark-container.tsx
index a6f8192e..7e1fc6c4 100644
--- a/src/domains/home/components/bookmark-container/bookmark-container.tsx
+++ b/src/domains/home/components/bookmark-container/bookmark-container.tsx
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
-import { BookmarkIcon } from '@/shared/components/icons';
+import { BookmarkButton } from '@/shared/components/ui';
type BookmarkOverlayVariant = 'card' | 'summary';
@@ -29,24 +29,11 @@ export const BookmarkContainer = ({
{children}
-
+
);
};
diff --git a/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx b/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx
index 7f079ba9..517ea6d2 100644
--- a/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx
+++ b/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx
@@ -8,11 +8,9 @@ import { POST_MUTATION_OPTIONS } from '@/domains/posts/api/query';
import { PostRecruitmentStatusBottomSheet } from '@/domains/posts/components/post-recruitment-status-bottom-sheet/post-recruitment-status-bottom-sheet';
import { PostRecruitmentStatusButton } from '@/domains/posts/components/post-recruitment-status-button/post-recruitment-status-button';
import type { PostRecruitmentStatusTypes } from '@/domains/posts/model/post-recruitment-status';
-import { cn } from '@/lib/cn';
import { POST_QUERY_KEY, RECOMMENDATION_QUERY_KEY } from '@/shared/api';
import { defaultProfileImage } from '@/shared/assets/illustrations';
-import { BookmarkIcon } from '@/shared/components/icons';
-import { useToast } from '@/shared/components/ui';
+import { BookmarkButton, useToast } from '@/shared/components/ui';
import { CommonImage } from '@/shared/components/ui/common-image/common-image';
import { ROUTES } from '@/shared/config';
@@ -146,20 +144,11 @@ export const PostDetailProfileHeader = ({
/>
>
) : (
-
+ />
)}
);
diff --git a/src/shared/components/ui/bookmark-button/bookmark-button.tsx b/src/shared/components/ui/bookmark-button/bookmark-button.tsx
new file mode 100644
index 00000000..b28d331c
--- /dev/null
+++ b/src/shared/components/ui/bookmark-button/bookmark-button.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { cn } from '@/lib/cn';
+import { BookmarkIcon } from '@/shared/components/icons';
+
+interface BookmarkButtonProps {
+ isBookmarked: boolean;
+ onClick: () => void;
+ className?: string;
+}
+
+export const BookmarkButton = ({
+ isBookmarked,
+ onClick,
+ className,
+}: BookmarkButtonProps) => {
+ return (
+
+ );
+};
diff --git a/src/shared/components/ui/card-list/card-list-skeleton.tsx b/src/shared/components/ui/card-list/card-list-skeleton.tsx
new file mode 100644
index 00000000..9a3d12c9
--- /dev/null
+++ b/src/shared/components/ui/card-list/card-list-skeleton.tsx
@@ -0,0 +1,31 @@
+import { cn } from '@/lib/cn';
+import { Skeleton } from '@/shared/components/ui/skeleton/skeleton';
+
+const SKELETON_IMAGE_KEYS = [1, 2, 3, 4] as const;
+
+interface CardListSkeletonProps {
+ className?: string;
+}
+
+export const CardListSkeleton = ({ className }: CardListSkeletonProps) => {
+ return (
+
+
+
+
+
+
+
+ {SKELETON_IMAGE_KEYS.map((imageKey) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/shared/components/ui/card-list/card-list.tsx b/src/shared/components/ui/card-list/card-list.tsx
new file mode 100644
index 00000000..099a36b3
--- /dev/null
+++ b/src/shared/components/ui/card-list/card-list.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import Link from 'next/link';
+
+import { cn } from '@/lib/cn';
+import { BookmarkButton } from '@/shared/components/ui/bookmark-button/bookmark-button';
+import { CommonImage } from '@/shared/components/ui/common-image/common-image';
+
+interface CardListImage {
+ src: string;
+ alt: string;
+}
+
+interface CardListProps {
+ title: string;
+ description: string;
+ images: CardListImage[];
+ isBookmarked: boolean;
+ onBookmarkClick: () => void;
+ href?: string;
+ className?: string;
+}
+
+export const CardList = ({
+ title,
+ description,
+ images,
+ isBookmarked,
+ onBookmarkClick,
+ href,
+ className,
+}: CardListProps) => {
+ const information = (
+ <>
+ {title}
+
+ {description}
+
+ >
+ );
+
+ return (
+
+
+ {href ? (
+
+ {information}
+
+ ) : (
+
+ {information}
+
+ )}
+
+
+
+
+
+ {images.map(({ src, alt }) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/shared/components/ui/card-list/index.ts b/src/shared/components/ui/card-list/index.ts
new file mode 100644
index 00000000..1a266200
--- /dev/null
+++ b/src/shared/components/ui/card-list/index.ts
@@ -0,0 +1,2 @@
+export { CardList } from './card-list';
+export { CardListSkeleton } from './card-list-skeleton';
diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts
index f19096a7..8a89905f 100644
--- a/src/shared/components/ui/index.ts
+++ b/src/shared/components/ui/index.ts
@@ -8,6 +8,7 @@ export {
AsyncLoadingState,
type AsyncLoadingStateProps,
} from './async-boundary';
+export { BookmarkButton } from './bookmark-button/bookmark-button';
export { BottomActionBar } from './bottom-action-bar/bottom-action-bar';
export { BottomSheet } from './bottom-sheet/bottom-sheet';
export { Button, type ButtonProps } from './button/button';
@@ -15,6 +16,7 @@ export { IconButton, type IconButtonProps } from './button/icon-button';
export { Card } from './card/card';
export { CardDate } from './card/card-date';
export { PostStatusTag, type RecruitmentStatus, Tag } from './card/card-tag';
+export { CardList, CardListSkeleton } from './card-list';
export { Chip, ChipButton } from './chip/chip';
export { ChipGroup, type ChipGroupProps } from './chip-group/chip-group';
export { CommonImage } from './common-image/common-image';
@@ -31,6 +33,7 @@ export { ProfileImageInput } from './profile-image-input/profile-image-input';
export { ProgressBar } from './progress-bar/progress-bar';
export { Searchbar, type SearchbarSize } from './searchbar/searchbar';
export { SearchbarWithDropdown } from './searchbar/searchbar-with-dropdown';
+export { Skeleton } from './skeleton/skeleton';
export { Tab } from './tab/tab';
export { TextArea, type TextAreaStatus } from './text-area/text-area';
export { TextField, type TextFieldStatus } from './text-field/text-field';
diff --git a/src/shared/components/ui/skeleton/skeleton.tsx b/src/shared/components/ui/skeleton/skeleton.tsx
new file mode 100644
index 00000000..82f4ca00
--- /dev/null
+++ b/src/shared/components/ui/skeleton/skeleton.tsx
@@ -0,0 +1,14 @@
+import { cn } from '@/lib/cn';
+
+interface SkeletonProps {
+ className?: string;
+}
+
+export const Skeleton = ({ className }: SkeletonProps) => {
+ return (
+
+ );
+};
diff --git a/src/styles/animations.css b/src/styles/animations.css
new file mode 100644
index 00000000..02a6c928
--- /dev/null
+++ b/src/styles/animations.css
@@ -0,0 +1,30 @@
+@keyframes skeleton-gradient-wave {
+ to {
+ transform: translateX(100%);
+ }
+}
+
+@utility animate-skeleton-wave {
+ position: relative;
+ overflow: hidden;
+
+ &::after {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 100deg,
+ transparent 20%,
+ color-mix(in srgb, var(--color-white) 55%, transparent) 50%,
+ transparent 80%
+ );
+ content: '';
+ transform: translateX(-100%);
+ animation: skeleton-gradient-wave 1.7s linear infinite;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .animate-skeleton-wave::after {
+ animation: none;
+ }
+}