Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pnpm lint # ESLint (flat config, includes react-hooks rules)
pnpm typecheck # tsc --noEmit
pnpm example # Run the CSR example (Vite, :3001)
pnpm example:next # Run the SSR example (Next.js, :3002)
pnpm website # Run the promo website (Next.js, :3003)

# Run a specific test by name pattern
pnpm vitest run -t "test name pattern"
Expand Down Expand Up @@ -77,6 +78,10 @@ React is the only external (peer dependency). Bundle budgets: everything ≤ 2 k
- `examples/basic` — Vite CSR app importing the library source (`../../src`) directly.
- `examples/nextjs` — Next.js 15 App Router app consuming the **built package** via `"react-device-check": "link:../.."` — run `pnpm build` at the root before starting it.

### Website

`website/` is a standalone Next.js 15 promo/landing site (own lockfile, not a workspace member) consuming the **published npm package** — unlike both examples, it needs no root build. English at `/`, Korean at `/ko` via two route-group root layouts (each sets its own `<html lang>`); hreflang/canonical/OG metadata come from `website/lib/seo.ts` (`SITE_URL` is the single deploy-URL definition). The OG image is the static `website/public/og.png`, referenced explicitly in `lib/seo.ts` (the `opengraph-image` file convention does not inject meta tags across route-group root layouts). Deployed on Vercel with Root Directory = `website`; excluded from CI, lint, size-limit, and the Playwright E2E matrix. The examples' ports and `data-testid` contracts are untouched by it.

### Package manager

This project uses `pnpm` and Node 20.x (see `.nvmrc`). Use `pnpm` for all install/run commands.
36 changes: 19 additions & 17 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

[English](README.md) | [한국어](README.ko.md)

**웹사이트 / 라이브 데모**: [react-device-check-site.vercel.app/ko](https://react-device-check-site.vercel.app/ko)

**경량 · 정확 · SSR-safe React 기기 판별 훅.** 사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS인지를 의존성 0개, 전체 ~1.5 kB(min+brotli)로 판별합니다. Next.js에서 hydration 에러가 발생하지 않습니다.

2026년의 기기 판별은 보기보다 어렵습니다. iPad는 자신을 Mac이라고 위장하고, Chrome은 User-Agent 문자열을 동결했으며(모든 안드로이드 모델명이 `K`로 보고됨), Samsung DeX는 폰에서 데스크톱 리눅스 UA를 보내고, iOS 26은 OS 버전 토큰을 영구 동결했습니다. `react-device-check`는 여전히 동작하는 신호들 — User-Agent Client Hints, UA 문자열, `maxTouchPoints` 교차검증 — 을 정규식 데이터베이스가 아닌 작고 결정론적인 판별 트리로 융합합니다.
Expand Down Expand Up @@ -88,16 +90,16 @@ function DownloadButton() {

전체 기기 스냅샷을 반환하고 반응형 변경을 구독합니다.

| 필드 | 타입 | 수명 | 설명 |
| ---------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------ |
| `type` | `'mobile' \| 'tablet' \| 'desktop'` | 정적 | 기기 클래스 |
| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | 정적 | OS 계열 |
| `isMobile` | `boolean` | 정적 | `type === 'mobile'` 축약 |
| `isTablet` | `boolean` | 정적 | `type === 'tablet'` 축약 |
| `isDesktop` | `boolean` | 정적 | `type === 'desktop'` 축약 |
| `isTouchPrimary` | `boolean` | 반응형 | `(pointer: coarse)` — 마우스 연결 시(DeX, iPad) 실시간 전환 |
| `orientation` | `'portrait' \| 'landscape'` | 반응형 | 뷰포트 방향, 회전 시 갱신 |
| `isHydrated` | `boolean` | — | 서버·hydration 첫 페인트에서 `false`, 직후 `true` |
| 필드 | 타입 | 수명 | 설명 |
| ---------------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------- |
| `type` | `'mobile' \| 'tablet' \| 'desktop'` | 정적 | 기기 클래스 |
| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | 정적 | OS 계열 |
| `isMobile` | `boolean` | 정적 | `type === 'mobile'` 축약 |
| `isTablet` | `boolean` | 정적 | `type === 'tablet'` 축약 |
| `isDesktop` | `boolean` | 정적 | `type === 'desktop'` 축약 |
| `isTouchPrimary` | `boolean` | 반응형 | `(pointer: coarse)` — 마우스 연결 시(DeX, iPad) 실시간 전환 |
| `orientation` | `'portrait' \| 'landscape'` | 반응형 | 뷰포트 방향, 회전 시 갱신 |
| `isHydrated` | `boolean` | — | 서버·hydration 첫 페인트에서 `false`, 직후 `true` |

> **참고:** `type`과 `os`는 의도적으로 세션당 고정입니다. UA 사실은 페이지 리로드 없이 변하지 않으며, 고정 유지가 UI 흔들림을 방지합니다. 뷰포트 의존적인 것은 반응형 필드(또는 CSS)를 사용하세요.

Expand Down Expand Up @@ -127,13 +129,13 @@ const { type, os } = detectDevice({ ua: req.headers['user-agent'] });
detectDevice(undefined, { fallback: { type: 'mobile' } });
```

| `DetectionInput` 필드 | 클라이언트에서 읽는 곳 |
| ---------------------- | ---------------------------- |
| `ua` | `navigator.userAgent` |
| `uaData` | `navigator.userAgentData` |
| `maxTouchPoints` | `navigator.maxTouchPoints` |
| `platform` | `navigator.platform` |
| `screen` | `screen.width` / `height` |
| `DetectionInput` 필드 | 클라이언트에서 읽는 곳 |
| --------------------- | -------------------------- |
| `ua` | `navigator.userAgent` |
| `uaData` | `navigator.userAgentData` |
| `maxTouchPoints` | `navigator.maxTouchPoints` |
| `platform` | `navigator.platform` |
| `screen` | `screen.width` / `height` |

### `getNavigatorInput(): DetectionInput | undefined`

Expand Down
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

[English](README.md) | [한국어](README.ko.md)

**Website / live demo**: [react-device-check-site.vercel.app](https://react-device-check-site.vercel.app)

**Lightweight, accurate, SSR-safe React hooks for device detection.** Know whether your user is on a phone, tablet, or desktop — and which OS — with zero dependencies, ~1.5 kB (min+brotli) for everything, and no hydration errors in Next.js.

Detecting devices in 2026 is harder than it looks: iPads masquerade as Macs, Chrome froze its User-Agent string (every Android model reports `K`), Samsung DeX sends a desktop Linux UA from a phone, and iOS 26 froze its OS version token forever. `react-device-check` fuses the signals that still work — User-Agent Client Hints, the UA string, and `maxTouchPoints` cross-checks — into a small deterministic decision tree instead of a regex database.
Expand Down Expand Up @@ -88,16 +90,16 @@ function DownloadButton() {

Returns the full device snapshot and subscribes to reactive changes.

| Field | Type | Lifetime | Description |
| ---------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------ |
| `type` | `'mobile' \| 'tablet' \| 'desktop'` | static | Device class |
| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | static | OS family |
| `isMobile` | `boolean` | static | Sugar for `type === 'mobile'` |
| `isTablet` | `boolean` | static | Sugar for `type === 'tablet'` |
| `isDesktop` | `boolean` | static | Sugar for `type === 'desktop'` |
| `isTouchPrimary` | `boolean` | reactive | `(pointer: coarse)` — flips live when a mouse is attached (DeX, iPad) |
| `orientation` | `'portrait' \| 'landscape'` | reactive | Viewport orientation, updates on rotation |
| `isHydrated` | `boolean` | — | `false` on the server and hydration first paint, `true` right after |
| Field | Type | Lifetime | Description |
| ---------------- | -------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- |
| `type` | `'mobile' \| 'tablet' \| 'desktop'` | static | Device class |
| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | static | OS family |
| `isMobile` | `boolean` | static | Sugar for `type === 'mobile'` |
| `isTablet` | `boolean` | static | Sugar for `type === 'tablet'` |
| `isDesktop` | `boolean` | static | Sugar for `type === 'desktop'` |
| `isTouchPrimary` | `boolean` | reactive | `(pointer: coarse)` — flips live when a mouse is attached (DeX, iPad) |
| `orientation` | `'portrait' \| 'landscape'` | reactive | Viewport orientation, updates on rotation |
| `isHydrated` | `boolean` | — | `false` on the server and hydration first paint, `true` right after |

> **Note:** `type` and `os` are intentionally static per session. User-agent facts cannot change without a page load, and keeping them stable prevents UI flapping. Use the reactive fields (or CSS) for anything viewport-dependent.

Expand Down Expand Up @@ -127,13 +129,13 @@ const { type, os } = detectDevice({ ua: req.headers['user-agent'] });
detectDevice(undefined, { fallback: { type: 'mobile' } });
```

| `DetectionInput` field | Read from (client) |
| ---------------------- | --------------------------- |
| `ua` | `navigator.userAgent` |
| `uaData` | `navigator.userAgentData` |
| `maxTouchPoints` | `navigator.maxTouchPoints` |
| `platform` | `navigator.platform` |
| `screen` | `screen.width` / `height` |
| `DetectionInput` field | Read from (client) |
| ---------------------- | -------------------------- |
| `ua` | `navigator.userAgent` |
| `uaData` | `navigator.userAgentData` |
| `maxTouchPoints` | `navigator.maxTouchPoints` |
| `platform` | `navigator.platform` |
| `screen` | `screen.width` / `height` |

### `getNavigatorInput(): DetectionInput | undefined`

Expand Down
12 changes: 11 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';

export default tseslint.config(
{ ignores: ['dist/', 'coverage/', 'examples/', 'node_modules/', 'test-results/', 'playwright-report/'] },
{
ignores: [
'dist/',
'coverage/',
'examples/',
'website/',
'node_modules/',
'test-results/',
'playwright-report/',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"typecheck": "tsc --noEmit && tsc -p tsconfig.tools.json",
"example": "cd examples/basic && pnpm install && pnpm dev",
"example:next": "cd examples/nextjs && pnpm install && pnpm dev",
"website": "cd website && pnpm install && pnpm dev",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
Expand Down
17 changes: 17 additions & 0 deletions website/app/(en)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { inter, jetbrainsMono } from '@/lib/fonts';
import { SITE_URL } from '@/lib/seo';
import '../globals.css';

// Fallback for routes without their own metadata (e.g. not-found); pages override via buildMetadata
export const metadata: Metadata = { metadataBase: new URL(SITE_URL) };

// Route groups give each locale its own root layout so <html lang> is correct per URL
export default function EnLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body>{children}</body>
</html>
);
}
10 changes: 10 additions & 0 deletions website/app/(en)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import Landing from '@/components/Landing';
import { en } from '@/content/en';
import { buildMetadata } from '@/lib/seo';

export const metadata: Metadata = buildMetadata('en');

export default function HomePage() {
return <Landing locale="en" strings={en} />;
}
10 changes: 10 additions & 0 deletions website/app/(ko)/ko/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import Landing from '@/components/Landing';
import { ko } from '@/content/ko';
import { buildMetadata } from '@/lib/seo';

export const metadata: Metadata = buildMetadata('ko');

export default function KoHomePage() {
return <Landing locale="ko" strings={ko} />;
}
17 changes: 17 additions & 0 deletions website/app/(ko)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { inter, jetbrainsMono } from '@/lib/fonts';
import { SITE_URL } from '@/lib/seo';
import '../globals.css';

// Fallback for routes without their own metadata; pages override via buildMetadata
export const metadata: Metadata = { metadataBase: new URL(SITE_URL) };

// Second root layout for the /ko subtree — navigation across locales is a full page load by design
export default function KoLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body>{children}</body>
</html>
);
}
Loading
Loading