diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
new file mode 100644
index 00000000..69580e6f
--- /dev/null
+++ b/frontend/src/components/ui/Badge.tsx
@@ -0,0 +1,92 @@
+import type { ReactNode } from "react";
+
+export type BadgeVariant =
+ | "success"
+ | "warning"
+ | "danger"
+ | "info"
+ | "neutral";
+export type BadgeSize = "sm" | "md";
+
+export interface BadgeProps {
+ variant: BadgeVariant;
+ size?: BadgeSize;
+ icon?: ReactNode;
+ children: ReactNode;
+}
+
+const VARIANT_STYLES: Record<
+ BadgeVariant,
+ { background: string; color: string; border: string }
+> = {
+ success: {
+ background: "var(--success-soft)",
+ color: "var(--success)",
+ border: "transparent",
+ },
+ warning: {
+ background: "var(--warn-soft)",
+ color: "var(--warn)",
+ border: "transparent",
+ },
+ danger: {
+ background: "var(--danger-soft)",
+ color: "var(--danger)",
+ border: "transparent",
+ },
+ info: {
+ background: "var(--accent-soft)",
+ color: "var(--accent)",
+ border: "transparent",
+ },
+ neutral: {
+ background: "var(--bg-card-alt)",
+ color: "var(--fg-secondary)",
+ border: "var(--border-card)",
+ },
+};
+
+const SIZE_STYLES: Record<
+ BadgeSize,
+ { fontSize: number; padding: string; gap: number }
+> = {
+ sm: { fontSize: 11, padding: "2px 6px", gap: 3 },
+ md: { fontSize: 12, padding: "3px 8px", gap: 4 },
+};
+
+/**
+ * Badge — inline-flex semantic badge with variants aligned to design tokens.
+ * Variants: success | warning | danger | info | neutral.
+ * Icon slot accepts any ReactNode (use Lucide icons, size 12-14px).
+ */
+export function Badge({ variant, size = "md", icon, children }: BadgeProps) {
+ const vs = VARIANT_STYLES[variant];
+ const ss = SIZE_STYLES[size];
+
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/Drawer.tsx
new file mode 100644
index 00000000..9b6817fc
--- /dev/null
+++ b/frontend/src/components/ui/Drawer.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import {
+ useEffect,
+ useRef,
+ type CSSProperties,
+ type KeyboardEvent,
+ type MouseEvent,
+ type ReactNode,
+} from "react";
+
+export interface DrawerProps {
+ open: boolean;
+ onClose: () => void;
+ side: "right" | "bottom";
+ /** CSS width string — default "360px" for right, "100%" for bottom */
+ width?: string;
+ children: ReactNode;
+}
+
+const FOCUSABLE_SELECTORS =
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
+
+/**
+ * Drawer — accessible slide-in overlay panel.
+ * - side="right" → right side panel (default 360px width)
+ * - side="bottom" → bottom-sheet (full width, mobile-first)
+ * Click outside / Escape → onClose.
+ * Focus trap: Tab cycles within open drawer.
+ */
+export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
+ const drawerRef = useRef(null);
+ const previousFocusRef = useRef(null);
+
+ // Save focus and restore on close
+ useEffect(() => {
+ if (open) {
+ previousFocusRef.current = document.activeElement;
+ // Move focus into drawer on next frame
+ const id = requestAnimationFrame(() => {
+ const el =
+ drawerRef.current?.querySelector(FOCUSABLE_SELECTORS);
+ el?.focus();
+ });
+ return () => cancelAnimationFrame(id);
+ } else {
+ if (previousFocusRef.current instanceof HTMLElement) {
+ previousFocusRef.current.focus();
+ }
+ }
+ }, [open]);
+
+ // Escape key
+ useEffect(() => {
+ if (!open) return;
+ const handleKeyDown = (e: globalThis.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [open, onClose]);
+
+ // Prevent body scroll while open
+ useEffect(() => {
+ if (open) {
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }
+ }, [open]);
+
+ const handleOverlayClick = (e: MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ onClose();
+ }
+ };
+
+ const handleDrawerKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ const drawer = drawerRef.current;
+ if (!drawer) return;
+ const focusable = Array.from(
+ drawer.querySelectorAll(FOCUSABLE_SELECTORS),
+ ).filter((el) => !el.closest("[aria-hidden]"));
+ if (focusable.length === 0) return;
+ const first = focusable[0] as HTMLElement;
+ const last = focusable[focusable.length - 1] as HTMLElement;
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ };
+
+ const defaultWidth = side === "right" ? "360px" : "100%";
+ const resolvedWidth = width ?? defaultWidth;
+
+ const panelStyle: CSSProperties =
+ side === "right"
+ ? {
+ position: "fixed",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: resolvedWidth,
+ maxWidth: "100vw",
+ background: "var(--bg-card)",
+ boxShadow: "0 0 32px rgba(0,0,0,0.18)",
+ display: "flex",
+ flexDirection: "column",
+ transform: open ? "translateX(0)" : "translateX(100%)",
+ transition: "transform 180ms ease",
+ zIndex: 200,
+ overflowY: "auto",
+ }
+ : {
+ position: "fixed",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: "100%",
+ maxHeight: "85vh",
+ background: "var(--bg-card)",
+ boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
+ borderRadius: "12px 12px 0 0",
+ display: "flex",
+ flexDirection: "column",
+ transform: open ? "translateY(0)" : "translateY(100%)",
+ transition: "transform 180ms ease",
+ zIndex: 200,
+ overflowY: "auto",
+ };
+
+ if (!open) {
+ // Keep in DOM for transition but aria-hide
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/HeadlineBar.tsx b/frontend/src/components/ui/HeadlineBar.tsx
new file mode 100644
index 00000000..9f2e235e
--- /dev/null
+++ b/frontend/src/components/ui/HeadlineBar.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from "react";
+
+export interface HeadlineBarProps {
+ title: string;
+ subtitle?: string;
+ rightSlot?: ReactNode;
+}
+
+/**
+ * HeadlineBar — dark verdict/headline strip used across Site Finder and analytics.
+ * Background: var(--bg-headline) = #0F172A (slate-900).
+ * Text: var(--fg-on-dark) / var(--fg-on-dark-muted).
+ */
+export function HeadlineBar({ title, subtitle, rightSlot }: HeadlineBarProps) {
+ return (
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {rightSlot && (
+
+ {rightSlot}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/__tests__/Badge.test.tsx b/frontend/src/components/ui/__tests__/Badge.test.tsx
new file mode 100644
index 00000000..96582e6b
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/Badge.test.tsx
@@ -0,0 +1,52 @@
+/**
+ * Tests require: jest + @testing-library/react + @testing-library/jest-dom
+ * npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
+ */
+import { render, screen } from "@testing-library/react";
+import { Badge } from "../Badge";
+
+describe("Badge", () => {
+ it("renders children text", () => {
+ render(Свободен);
+ expect(screen.getByText("Свободен")).toBeInTheDocument();
+ });
+
+ it("applies success token background via inline style", () => {
+ const { container } = render(OK);
+ const span = container.querySelector("span");
+ expect(span?.style.background).toBe("var(--success-soft)");
+ expect(span?.style.color).toBe("var(--success)");
+ });
+
+ it("applies danger token for danger variant", () => {
+ const { container } = render(Риск);
+ const span = container.querySelector("span");
+ expect(span?.style.background).toBe("var(--danger-soft)");
+ expect(span?.style.color).toBe("var(--danger)");
+ });
+
+ it("renders icon slot when provided", () => {
+ render(
+ }>
+ Внимание
+ ,
+ );
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+
+ it("applies sm size with smaller font", () => {
+ const { container } = render(
+
+ Нейтрально
+ ,
+ );
+ const span = container.querySelector("span");
+ expect(span?.style.fontSize).toBe("11px");
+ });
+
+ it("applies md size by default", () => {
+ const { container } = render(Инфо);
+ const span = container.querySelector("span");
+ expect(span?.style.fontSize).toBe("12px");
+ });
+});
diff --git a/frontend/src/components/ui/__tests__/Drawer.test.tsx b/frontend/src/components/ui/__tests__/Drawer.test.tsx
new file mode 100644
index 00000000..2cd2a8c3
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/Drawer.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * Tests require: jest + @testing-library/react + @testing-library/jest-dom
+ * npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
+ */
+import { render, screen, fireEvent } from "@testing-library/react";
+import { Drawer } from "../Drawer";
+
+describe("Drawer", () => {
+ it("renders children when open=true", () => {
+ render(
+ {}} side="right">
+ Контент панели
+ ,
+ );
+ expect(screen.getByText("Контент панели")).toBeInTheDocument();
+ });
+
+ it("does not show children interactively when open=false", () => {
+ render(
+ {}} side="right">
+ Скрытый контент
+ ,
+ );
+ // Panel is in DOM but aria-hidden overlay
+ const overlay = document.querySelector('[aria-hidden="true"]');
+ expect(overlay).toBeInTheDocument();
+ });
+
+ it("calls onClose when Escape is pressed", () => {
+ const onClose = jest.fn();
+ render(
+
+ Контент
+ ,
+ );
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders with role=dialog when open", () => {
+ render(
+ {}} side="bottom">
+ Bottom sheet
+ ,
+ );
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("applies translateY(0) for bottom side when open", () => {
+ const { container } = render(
+ {}} side="bottom">
+ Bottom
+ ,
+ );
+ // The panel div is second child of dialog div
+ const dialog = container.querySelector('[role="dialog"]');
+ const panel = dialog?.querySelector("div");
+ expect(panel?.style.transform).toBe("translateY(0)");
+ });
+});
diff --git a/frontend/src/components/ui/__tests__/HeadlineBar.test.tsx b/frontend/src/components/ui/__tests__/HeadlineBar.test.tsx
new file mode 100644
index 00000000..0151366c
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/HeadlineBar.test.tsx
@@ -0,0 +1,37 @@
+/**
+ * Tests require: jest + @testing-library/react + @testing-library/jest-dom
+ * npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
+ */
+import { render, screen } from "@testing-library/react";
+import { HeadlineBar } from "../HeadlineBar";
+
+describe("HeadlineBar", () => {
+ it("renders title text", () => {
+ render();
+ expect(screen.getByText("ПОДХОДИТ · участок 0.82 га")).toBeInTheDocument();
+ });
+
+ it("renders subtitle when provided", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Кадастровая стоимость 14.2 млн ₽"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render subtitle section when omitted", () => {
+ const { container } = render();
+ // subtitle div has marginTop style — not present when subtitle is undefined
+ const subtitleDivs = container.querySelectorAll("div > div > div");
+ expect(subtitleDivs).toHaveLength(0);
+ });
+
+ it("renders rightSlot content", () => {
+ render(Export} />);
+ expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
+ });
+});