@djangocfg/ui-nextjs 2.1.294 → 2.1.297

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,23 +43,20 @@ Peer dependencies: `next`, `next-intl`, `react`, `tailwindcss`.
43
43
  ### From ui-core (60)
44
44
  All components from `@djangocfg/ui-core` are re-exported.
45
45
 
46
- ### Next.js Specific (11)
46
+ ### Next.js Specific
47
47
 
48
48
  | Component | Description |
49
49
  |-----------|-------------|
50
- | `NextLink` | Locale-aware link wrapper |
51
- | `NextButtonLink` | Button-styled locale-aware link with variants |
52
- | `Sidebar` | Locale-aware collapsible sidebar; `SidebarTrigger` shows an OS-aware tooltip (`⌘+B` / `Ctrl+B`); `SidebarMenuButton` accepts `tooltip` for icon-rail hints |
53
- | `Breadcrumb` | Locale-aware breadcrumbs |
50
+ | `Sidebar` | Collapsible sidebar; `SidebarTrigger` shows an OS-aware tooltip (`⌘+B` / `Ctrl+B`); `SidebarMenuButton` accepts `tooltip` for icon-rail hints |
51
+ | `Breadcrumb` | Breadcrumb primitives (uses ui-core `<Link>`) |
54
52
  | `BreadcrumbNavigation` | High-level breadcrumb component |
55
- | `NavigationMenu` | Locale-aware navigation menu |
56
- | `Menubar` | Locale-aware menubar |
57
- | `DropdownMenu` | Menu with locale-aware link items |
58
- | `Pagination` | Locale-aware page links |
53
+ | `Pagination` | Pagination link primitives |
59
54
  | `SSRPagination` | Server-compatible pagination |
60
- | `DownloadButton` | Uses `localStorage` for auth |
55
+ | `DropdownMenu` | Local override of ui-core `DropdownMenu` (kept for legacy ui-nextjs callers) |
61
56
 
62
- All link-based components use `next-intl` internally no configuration needed.
57
+ > **Links**: use `<Link>` and `<ButtonLink>` from `@djangocfg/ui-core/components` directly.
58
+ > Mounted via `NextLinkProvider` (in `BaseApp` from `@djangocfg/layouts`), they
59
+ > render through `next/link` — so prefetch / RSC handling work as expected.
63
60
 
64
61
  ### MultiSelect Pro
65
62
  `MultiSelectPro` `MultiSelectProAsync` — Advanced multi-select with async loading
@@ -134,60 +131,56 @@ See ui-core README for full documentation.
134
131
 
135
132
  ```tsx
136
133
  import {
137
- Button, Card, Input, // from ui-core
138
- NextButtonLink, Sidebar // Next.js locale-aware
134
+ Button, ButtonLink, Card, Input, Sidebar
139
135
  } from '@djangocfg/ui-nextjs';
140
- import { useNavigation, useLocalStorage } from '@djangocfg/ui-nextjs/hooks';
136
+ import { useNavigate } from '@djangocfg/ui-core/hooks';
137
+ import { useLocalStorage } from '@djangocfg/ui-nextjs/hooks';
141
138
 
142
139
  function Example() {
143
- const { router, pathname } = useNavigation();
140
+ const { navigate } = useNavigate();
144
141
  const [saved, setSaved] = useLocalStorage('form', null);
145
142
 
146
143
  return (
147
144
  <Sidebar>
148
145
  <Card>
149
146
  <Input placeholder="Email" />
150
- <Button onClick={() => router.push('/dashboard')}>
147
+ <Button onClick={() => navigate('/dashboard')}>
151
148
  Submit
152
149
  </Button>
153
- <NextButtonLink href="/docs" variant="outline">
150
+ <ButtonLink href="/docs" variant="outline">
154
151
  Documentation
155
- </NextButtonLink>
152
+ </ButtonLink>
156
153
  </Card>
157
154
  </Sidebar>
158
155
  );
159
156
  }
160
157
  ```
161
158
 
162
- ### NextButtonLink Examples
159
+ ### `<ButtonLink>` examples
160
+
161
+ `<ButtonLink>` lives in `@djangocfg/ui-core/components`. In Next.js apps it renders
162
+ through `next/link` automatically (via `NextLinkProvider` mounted in `BaseApp`).
163
+ In Wails / Electron / Vite consumers it falls back to a plain `<a>` driven by
164
+ `useNavigate`.
163
165
 
164
166
  ```tsx
165
- import { NextButtonLink } from '@djangocfg/ui-nextjs';
167
+ import { ButtonLink } from '@djangocfg/ui-core/components';
166
168
  import { ArrowLeft, Settings } from 'lucide-react';
167
169
 
168
- // Basic — automatically gets locale prefix
169
- <NextButtonLink href="/dashboard">Dashboard</NextButtonLink>
170
+ <ButtonLink href="/dashboard">Dashboard</ButtonLink>
170
171
 
171
- // With variants
172
- <NextButtonLink href="/settings" variant="outline" size="sm">
172
+ <ButtonLink href="/settings" variant="outline" size="sm">
173
173
  <Settings className="h-4 w-4 mr-2" />
174
174
  Settings
175
- </NextButtonLink>
175
+ </ButtonLink>
176
176
 
177
- // Icon button (back navigation)
178
- <NextButtonLink href="/list" variant="ghost" size="icon">
177
+ <ButtonLink href="/list" variant="ghost" size="icon">
179
178
  <ArrowLeft className="h-4 w-4" />
180
- </NextButtonLink>
181
-
182
- // With Next.js Link props
183
- <NextButtonLink href="/page" prefetch={false} replace scroll={false}>
184
- Navigate
185
- </NextButtonLink>
179
+ </ButtonLink>
186
180
 
187
- // With locale override
188
- <NextButtonLink href="/docs" locale="en">
189
- English Docs
190
- </NextButtonLink>
181
+ <ButtonLink href="/page" prefetch={false} replace>
182
+ Navigate (replaceState)
183
+ </ButtonLink>
191
184
  ```
192
185
 
193
186
  ## Styling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-nextjs",
3
- "version": "2.1.294",
3
+ "version": "2.1.297",
4
4
  "description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -85,11 +85,11 @@
85
85
  "check": "tsc --noEmit"
86
86
  },
87
87
  "peerDependencies": {
88
- "@djangocfg/api": "^2.1.294",
89
- "@djangocfg/i18n": "^2.1.294",
90
- "@djangocfg/nextjs": "^2.1.294",
91
- "@djangocfg/ui-core": "^2.1.294",
92
- "@djangocfg/ui-tools": "^2.1.294",
88
+ "@djangocfg/api": "^2.1.297",
89
+ "@djangocfg/i18n": "^2.1.297",
90
+ "@djangocfg/nextjs": "^2.1.297",
91
+ "@djangocfg/ui-core": "^2.1.297",
92
+ "@djangocfg/ui-tools": "^2.1.297",
93
93
  "@types/react": "^19.1.0",
94
94
  "@types/react-dom": "^19.1.0",
95
95
  "consola": "^3.4.2",
@@ -112,7 +112,7 @@
112
112
  "react-chartjs-2": "^5.3.0"
113
113
  },
114
114
  "devDependencies": {
115
- "@djangocfg/typescript-config": "^2.1.294",
115
+ "@djangocfg/typescript-config": "^2.1.297",
116
116
  "@radix-ui/react-dropdown-menu": "^2.1.16",
117
117
  "@radix-ui/react-slot": "^1.2.4",
118
118
  "@types/node": "^24.7.2",
@@ -2,10 +2,9 @@ import React from 'react';
2
2
  import moment from 'moment';
3
3
 
4
4
  import {
5
- Badge, Card, CardContent, CardDescription, CardHeader, CardTitle
5
+ Badge, ButtonLink, Card, CardContent, CardDescription, CardHeader, CardTitle
6
6
  } from '@djangocfg/ui-core/components';
7
7
 
8
- import { NextButtonLink as ButtonLink } from '../components/next-link';
9
8
  import { cn } from '@djangocfg/ui-core/lib';
10
9
 
11
10
  export type ArticleType = 'security' | 'release' | 'announcement' | 'feature';
@@ -1,9 +1,8 @@
1
1
  import React from 'react';
2
2
 
3
+ import { ButtonLink } from '@djangocfg/ui-core/components';
3
4
  import { cn } from '@djangocfg/ui-core/lib';
4
5
 
5
- import { NextButtonLink as ButtonLink } from '../components/next-link';
6
-
7
6
  interface HeroProps {
8
7
  title: string;
9
8
  subtitle?: string;
@@ -2,9 +2,8 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { ArrowRight, Sparkles } from 'lucide-react';
5
+ import { ButtonLink } from '@djangocfg/ui-core/components';
5
6
  import { cn } from '@djangocfg/ui-core/lib';
6
-
7
- import { NextButtonLink as ButtonLink } from '../../components/next-link';
8
7
  import type { SplitHeroBadge, SplitHeroAction, SplitHeroFeature } from './types';
9
8
 
10
9
  interface SplitHeroContentProps {
@@ -5,10 +5,9 @@ import { ArrowRight, Sparkles, Wand2 } from 'lucide-react';
5
5
  import React from 'react';
6
6
 
7
7
  import {
8
- Button, CopyButton, Sticky, Tooltip, TooltipContent, TooltipTrigger
8
+ Button, ButtonLink, CopyButton, Sticky, Tooltip, TooltipContent, TooltipTrigger
9
9
  } from '@djangocfg/ui-core/components';
10
10
 
11
- import { NextButtonLink as ButtonLink } from '../components/next-link';
12
11
  import { cn } from '@djangocfg/ui-core/lib';
13
12
 
14
13
  import { AnimatedBackground, BackgroundVariant} from '../animations';
@@ -1,4 +1,4 @@
1
- import { Link } from '../lib/navigation';
1
+ import { Link } from '@djangocfg/ui-core/components';
2
2
  import React from 'react';
3
3
 
4
4
  import {
@@ -1,9 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import { ChevronRight, MoreHorizontal } from 'lucide-react';
4
- import { Link } from '../lib/navigation';
5
4
  import * as React from 'react';
6
5
 
6
+ import { Link } from '@djangocfg/ui-core/components';
7
7
  import { cn } from '@djangocfg/ui-core/lib';
8
8
  import { Slot } from '@radix-ui/react-slot';
9
9
 
@@ -1,9 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import { Check, ChevronRight, Circle } from 'lucide-react';
4
- import { Link } from '../lib/navigation';
5
4
  import * as React from 'react';
6
5
 
6
+ import { Link } from '@djangocfg/ui-core/components';
7
7
  import { cn } from '@djangocfg/ui-core/lib';
8
8
  import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
9
9
 
@@ -6,15 +6,9 @@
6
6
  'use client';
7
7
 
8
8
  // Re-export all base components from ui-core
9
+ // (includes Link / ButtonLink — wired to next/link via NextLinkProvider in BaseApp)
9
10
  export * from '@djangocfg/ui-core/components';
10
11
 
11
- // Locale-aware Link (re-export from next-intl navigation)
12
- export { Link } from '../lib/navigation';
13
-
14
- // Link Components (Next.js)
15
- export { NextLink, NextButtonLink } from './next-link';
16
- export type { NextLinkProps, NextButtonLinkProps } from './next-link';
17
-
18
12
  // Navigation Components (Next.js)
19
13
  export { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis } from './breadcrumb';
20
14
  export { BreadcrumbNavigation } from './breadcrumb-navigation';
@@ -1,8 +1,7 @@
1
1
  import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
2
- import { Link } from '../lib/navigation';
3
2
  import * as React from 'react';
4
3
 
5
- import { ButtonProps, buttonVariants} from '@djangocfg/ui-core/components';
4
+ import { ButtonProps, Link, buttonVariants } from '@djangocfg/ui-core/components';
6
5
  import { cn } from '@djangocfg/ui-core/lib';
7
6
 
8
7
  const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { cva, VariantProps } from 'class-variance-authority';
4
4
  import { Menu, PanelLeft } from 'lucide-react';
5
- import { Link } from '../lib/navigation';
6
5
  import * as React from 'react';
7
6
 
7
+ import { Link } from '@djangocfg/ui-core/components';
8
+
8
9
  import {
9
10
  Button,
10
11
  Drawer,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { usePathname } from '../lib/navigation';
3
+ import { usePathname } from 'next/navigation';
4
4
  import React from 'react';
5
5
 
6
6
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
@@ -6,7 +6,7 @@
6
6
  'use client';
7
7
 
8
8
  // Locale-aware navigation hooks (next-intl)
9
- export { useRouter, usePathname } from '../lib/navigation';
9
+ export { useRouter, usePathname } from 'next/navigation';
10
10
  export { useNavigation } from './useNavigation';
11
11
 
12
12
  // Theme hook (standalone, no provider required)
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useRouter, usePathname, redirect } from '../lib/navigation';
3
+ import { useRouter, usePathname, redirect } from 'next/navigation';
4
4
 
5
5
  /**
6
6
  * Locale-aware navigation hook.
@@ -1,91 +0,0 @@
1
- 'use client';
2
-
3
- import * as React from 'react';
4
- import type { LinkProps as NextLinkBaseProps } from 'next/link';
5
- import { Link } from '../lib/navigation';
6
- import { buttonVariants } from '@djangocfg/ui-core/components';
7
- import type { VariantProps } from 'class-variance-authority';
8
- import { cn } from '@djangocfg/ui-core/lib';
9
-
10
- type BaseLinkProps = React.ComponentProps<typeof Link>;
11
-
12
- // ============================================================================
13
- // NextLink - Styled Next.js Link (text link style)
14
- // ============================================================================
15
-
16
- export interface NextLinkProps
17
- extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkBaseProps>,
18
- NextLinkBaseProps {
19
- locale?: string;
20
- children?: React.ReactNode;
21
- }
22
-
23
- /**
24
- * NextLink - Next.js Link with no additional styling
25
- *
26
- * Use this for regular navigation links. For button-styled links, use NextButtonLink.
27
- *
28
- * @example
29
- * <NextLink href="/about">About</NextLink>
30
- * <NextLink href="/docs" className="text-primary hover:underline">Docs</NextLink>
31
- */
32
- const NextLink = React.forwardRef<HTMLAnchorElement, NextLinkProps>(
33
- ({ children, locale: _locale, ...props }, ref) => {
34
- return (
35
- <Link ref={ref} {...props as BaseLinkProps}>
36
- {children}
37
- </Link>
38
- );
39
- }
40
- );
41
- NextLink.displayName = 'NextLink';
42
-
43
- // ============================================================================
44
- // NextButtonLink - Button styled Next.js Link
45
- // ============================================================================
46
-
47
- export interface NextButtonLinkProps
48
- extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkBaseProps>,
49
- NextLinkBaseProps,
50
- VariantProps<typeof buttonVariants> {
51
- locale?: string;
52
- children?: React.ReactNode;
53
- }
54
-
55
- /**
56
- * NextButtonLink - A button styled link using Next.js Link component
57
- *
58
- * Supports all Next.js Link props (prefetch, replace, scroll, etc.)
59
- * and all Button variant props (variant, size).
60
- *
61
- * @example
62
- * // Basic usage
63
- * <NextButtonLink href="/dashboard">Dashboard</NextButtonLink>
64
- *
65
- * // With variants
66
- * <NextButtonLink href="/settings" variant="outline" size="sm">Settings</NextButtonLink>
67
- *
68
- * // Icon button
69
- * <NextButtonLink href="/back" variant="ghost" size="icon">
70
- * <ArrowLeft className="h-4 w-4" />
71
- * </NextButtonLink>
72
- *
73
- * // With Next.js Link props
74
- * <NextButtonLink href="/page" prefetch={false} replace>Go</NextButtonLink>
75
- */
76
- const NextButtonLink = React.forwardRef<HTMLAnchorElement, NextButtonLinkProps>(
77
- ({ className, variant, size, children, locale: _locale, ...props }, ref) => {
78
- return (
79
- <Link
80
- className={cn(buttonVariants({ variant, size, className }))}
81
- ref={ref}
82
- {...props as BaseLinkProps}
83
- >
84
- {children}
85
- </Link>
86
- );
87
- }
88
- );
89
- NextButtonLink.displayName = 'NextButtonLink';
90
-
91
- export { NextLink, NextButtonLink };
@@ -1,12 +0,0 @@
1
- /**
2
- * Internal navigation utilities for ui-nextjs
3
- *
4
- * Uses standard next/link and next/navigation to avoid requiring
5
- * NextIntlClientProvider in the component tree. This makes ui-nextjs
6
- * work both with and without next-intl i18n setup.
7
- *
8
- * Apps that need locale-aware links should use their own navigation
9
- * module (e.g. @i18n/navigation) created via next-intl's createNavigation().
10
- */
11
- export { default as Link } from 'next/link';
12
- export { usePathname, useRouter, redirect } from 'next/navigation';
@@ -1,92 +0,0 @@
1
- # PWAInstall Documentation
2
-
3
- Comprehensive documentation for the PWAInstall snippet.
4
-
5
- ## Overview
6
-
7
- PWAInstall handles **Progressive Web App installation** on user devices (Add to Home Screen functionality).
8
-
9
- **Responsibility**: Device installation only (not push notifications)
10
-
11
- ## Documentation Structure
12
-
13
- ### `/research/`
14
- Research and best practices:
15
- - **[ios-android-install-flows.md](./research/ios-android-install-flows.md)** - iOS vs Android PWA installation patterns, limitations, and best practices (2024-2025)
16
-
17
- ### `/architecture/`
18
- Architecture and design:
19
- - Coming soon: Architecture decisions, component design, state management
20
-
21
- ### `/legacy/`
22
- Historical documentation:
23
- - Old architecture analysis (before snippet split)
24
- - Refactoring history
25
-
26
- ## Quick Navigation
27
-
28
- ### For Users
29
- Start here if you want to use PWAInstall:
30
- - [Main README](../README.md) - Quick start and API reference
31
- - [Migration Guide](../../MIGRATION.md) - Migrating from old PWA snippet
32
-
33
- ### For Contributors
34
- Start here if you want to understand or modify PWAInstall:
35
- - [iOS/Android Install Flows](./research/ios-android-install-flows.md) - Platform-specific behavior
36
- - [Architecture](./architecture/) - How it's built
37
-
38
- ### For Researchers
39
- Start here if you want to understand PWA installation patterns:
40
- - [Research](./research/) - Industry research and best practices
41
-
42
- ## Key Concepts
43
-
44
- ### Platform Asymmetry
45
-
46
- | Aspect | Android Chrome | iOS Safari |
47
- |--------|----------------|------------|
48
- | Install API | `beforeinstallprompt` | ❌ No API |
49
- | User effort | 1 tap | 3-4 taps |
50
- | Detection | Event-based | Heuristic |
51
- | Guidance | Optional | **Required** |
52
-
53
- **PWAInstall handles this asymmetry transparently.**
54
-
55
- ### Components
56
-
57
- ```
58
- A2HSHint (Unified hint)
59
- ├── Android → Native install prompt
60
- └── iOS → Visual guide (IOSGuide)
61
- ├── Mobile → IOSGuideDrawer
62
- └── Desktop → IOSGuideModal
63
- ```
64
-
65
- ### State Management
66
-
67
- ```
68
- useInstall() hook
69
- ├── Platform detection (isIOS, isAndroid, isSafari)
70
- ├── Installation state (isInstalled, canPrompt)
71
- └── Install action (install())
72
- ```
73
-
74
- ## Related Documentation
75
-
76
- - **[PushNotifications Docs](../../PushNotifications/@docs/)** - Web push notifications (separate concern)
77
- - **[Refactoring Summary](../../REFACTORING_SUMMARY.md)** - Why snippets were split
78
- - **[Migration Guide](../../MIGRATION.md)** - How to migrate from old PWA snippet
79
-
80
- ## Contributing
81
-
82
- When adding documentation:
83
- 1. **Research** → `/research/` - Industry patterns, browser behavior
84
- 2. **Architecture** → `/architecture/` - Design decisions, component structure
85
- 3. **Historical** → `/legacy/` - Old docs (keep for reference)
86
-
87
- ## Questions?
88
-
89
- - Implementation questions → See [Main README](../README.md)
90
- - Architecture questions → See [/architecture/](./architecture/)
91
- - Platform behavior → See [/research/](./research/)
92
- - Migration questions → See [Migration Guide](../../MIGRATION.md)
@@ -1,576 +0,0 @@
1
-
2
-
3
-
4
- ## PWA Install Flows: Modern Best Practices for React (2024-2025)
5
-
6
- Given your experience with Django and Next.js infrastructure, you'll appreciate that PWA install flows are fundamentally about **adaptive UX patterns**, not magic APIs. Let me break down the reality vs. the aspirations.
7
-
8
- ***
9
-
10
- ### **The Core Problem: iOS vs. Android Asymmetry**
11
-
12
- | Aspect | Android (Chrome) | iOS (Safari) |
13
- |--------|------------------|--------------|
14
- | **Install API** | `beforeinstallprompt` event + native prompt | ❌ No API, no native banner |
15
- | **User effort** | 1 tap (native prompt) | 3-4 taps (Share → Add to Home Screen) |
16
- | **App awareness** | Chrome auto-prompts if installable | **You must educate users manually** |
17
- | **Detection** | Straightforward event-based | Must use heuristics (Safari + mobile) |
18
- | **Persistence** | Browser remembers install state | No native tracking |
19
- | **After install** | `appinstalled` event fires | Must detect via `standalone` flag |
20
-
21
- **The brutal truth:** iOS Safari treats PWAs as "websites you happened to bookmark"—there's no concept of "installation" from Apple's perspective. You're responsible for education and guidance.
22
-
23
- ***
24
-
25
- ### **What's Actually Possible vs. Impossible on iOS**
26
-
27
- #### ✅ **Possible (2024-2025)**
28
-
29
- 1. **Detect if running as standalone (already installed)**
30
- ```javascript
31
- const isInstalled = window.matchMedia("(display-mode: standalone)").matches
32
- || navigator.standalone === true;
33
- ```
34
-
35
- 2. **Detect iOS + Safari combination**
36
- - User agent parsing (for initial load)
37
- - Browser capability detection
38
-
39
- 3. **Show custom guidance UI with visual/text instructions**
40
- - No technical restrictions on UI—you can display modal, banner, tooltip, etc.
41
-
42
- 4. **Persist user guidance state (show once)**
43
- - localStorage, IndexedDB, or cookies
44
-
45
- 5. **Use web standards:**
46
- - Web App Manifest (icon, splash screen, display mode)
47
- - Service Workers (offline, performance)
48
- - Media queries for standalone detection
49
-
50
- #### ❌ **Impossible (Hard Limits)**
51
-
52
- 1. **Programmatically trigger install prompt** ← iOS blocks this intentionally
53
- 2. **Native install banner** ← Apple doesn't expose one
54
- 3. **beforeinstallprompt event** ← iOS doesn't fire it
55
- 4. **Push notifications on PWA** ← Disabled by Apple for PWAs (only for native apps)
56
- 5. **Detect `appinstalled` event** ← iOS doesn't fire this
57
- 6. **Background sync / periodic background sync** ← Not available for PWAs on iOS
58
- 7. **File system access** ← Blocked by iOS sandbox
59
- 8. **Native app store integration** ← You're not in the App Store
60
-
61
- ***
62
-
63
- ### **Browser & Environment Detection (React Hook)**
64
-
65
- Here's a production-ready detection hook that handles all the cases:
66
-
67
- ```javascript
68
- // useInstallPrompt.js
69
- import { useEffect, useState } from 'react';
70
-
71
- export function useInstallPrompt() {
72
- const [state, setState] = useState({
73
- isIOS: false,
74
- isAndroid: false,
75
- isSafari: false,
76
- isChrome: false,
77
- isInstalled: false, // Already added to home screen
78
- canPrompt: false, // beforeinstallprompt available (Android)
79
- deferredPrompt: null,
80
- });
81
-
82
- useEffect(() => {
83
- // Detect OS
84
- const ua = navigator.userAgent.toLowerCase();
85
- const isIOS = /iphone|ipad|ipod/.test(ua);
86
- const isAndroid = /android/.test(ua);
87
-
88
- // Detect browser
89
- const isSafari = /safari/.test(ua) && !/chrome/.test(ua) && !/edge/.test(ua);
90
- const isChrome = /chrome|chromium/.test(ua);
91
-
92
- // Detect if already installed (running as PWA on home screen)
93
- const isInstalled =
94
- window.matchMedia("(display-mode: standalone)").matches ||
95
- navigator.standalone === true; // Legacy iOS check
96
-
97
- setState(prev => ({
98
- ...prev,
99
- isIOS,
100
- isAndroid,
101
- isSafari,
102
- isChrome,
103
- isInstalled,
104
- }));
105
- }, []);
106
-
107
- // Listen for beforeinstallprompt (Android Chrome only)
108
- useEffect(() => {
109
- const handleBeforeInstallPrompt = (e) => {
110
- e.preventDefault(); // Don't show native prompt yet
111
- setState(prev => ({
112
- ...prev,
113
- canPrompt: true,
114
- deferredPrompt: e,
115
- }));
116
- };
117
-
118
- window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
119
-
120
- // Clean up: if app gets installed, can't prompt again
121
- const handleAppInstalled = () => {
122
- setState(prev => ({
123
- ...prev,
124
- canPrompt: false,
125
- deferredPrompt: null,
126
- isInstalled: true,
127
- }));
128
- };
129
-
130
- window.addEventListener('appinstalled', handleAppInstalled);
131
-
132
- return () => {
133
- window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
134
- window.removeEventListener('appinstalled', handleAppInstalled);
135
- };
136
- }, []);
137
-
138
- // Trigger Android native prompt
139
- const promptInstall = async () => {
140
- if (!state.deferredPrompt) return null;
141
-
142
- state.deferredPrompt.prompt();
143
- const { outcome } = await state.deferredPrompt.userChoice;
144
- setState(prev => ({
145
- ...prev,
146
- deferredPrompt: null,
147
- canPrompt: false,
148
- }));
149
- return outcome; // 'accepted' or 'dismissed'
150
- };
151
-
152
- return {
153
- ...state,
154
- promptInstall,
155
- };
156
- }
157
- ```
158
-
159
- ***
160
-
161
- ### **Real-World UX Patterns That Actually Work**
162
-
163
- #### **Pattern 1: The Adaptive Install Banner (Recommended)**
164
-
165
- **For first-time visitors:**
166
-
167
- 1. **Android Chrome**: Show custom "Install" button in navbar/footer
168
- - Click → triggers native prompt via `beforeinstallprompt`
169
- - Non-intrusive, native look
170
-
171
- 2. **iOS Safari**: Show subtle banner/tooltip on first visit
172
- - Text: "Add to Home Screen for quick access"
173
- - Visual: Share icon + "Add to Home Screen" steps
174
- - Dismiss-able, one-time only
175
-
176
- 3. **Already installed**: Hide all prompts
177
-
178
- **Implementation strategy:**
179
- - Show Android button immediately (it's native, trusted)
180
- - Delay iOS banner 2-3 seconds (let them explore first)
181
- - Use localStorage to track "dismissed once" per user
182
- - Reset for new visitors (check last visit date)
183
-
184
- ***
185
-
186
- #### **Pattern 2: Context-Aware Prompts (Based on Engagement)**
187
-
188
- **Show iOS guidance when:**
189
- - User has spent 30+ seconds on the app
190
- - Completed first action (e.g., created note, searched)
191
- - Returning visitor (showed visit count in localStorage)
192
-
193
- **Logic:**
194
- ```javascript
195
- // Pseudocode
196
- useEffect(() => {
197
- if (isIOS && !isInstalled && !isDismissedRecently) {
198
- const timer = setTimeout(() => {
199
- if (engagementMetrics.timeSpent > 30000 || engagementMetrics.actions > 1) {
200
- showIOSGuideModal();
201
- }
202
- }, 2000); // Check after 2 seconds
203
-
204
- return () => clearTimeout(timer);
205
- }
206
- }, [isIOS, isInstalled]);
207
- ```
208
-
209
- **Why it works:**
210
- - Users are already bought-in (they've engaged)
211
- - Feels less spammy
212
- - Higher conversion rates
213
-
214
- ***
215
-
216
- #### **Pattern 3: Visual Inline Instructions (The iOS Workaround)**
217
-
218
- Since iOS has no API, show a **visual + text guide**:
219
-
220
- ```jsx
221
- <IOSInstallGuide />
222
- ```
223
-
224
- Show:
225
- 1. Screenshot of Safari toolbar
226
- 2. "Tap Share button (↗️)"
227
- 3. "Swipe down, tap 'Add to Home Screen'"
228
- 4. "Tap 'Add' in top-right"
229
- 5. "App appears on home screen"
230
-
231
- **Best practices:**
232
- - Use actual iOS system fonts and colors
233
- - Show real screenshots, not cartoons
234
- - Make dismissible with "I'll do it later" option
235
- - Track if dismissed (localStorage key)
236
-
237
- ***
238
-
239
- ### **Example React Component: Complete Install Manager**
240
-
241
- ```javascript
242
- // InstallManager.jsx
243
- import { useEffect, useState } from 'react';
244
- import { useInstallPrompt } from './useInstallPrompt';
245
- import IOSGuideModal from './modals/IOSGuideModal';
246
- import AndroidInstallButton from './buttons/AndroidInstallButton';
247
-
248
- export default function InstallManager() {
249
- const install = useInstallPrompt();
250
- const [showIOSGuide, setShowIOSGuide] = useState(false);
251
- const [dismissalTime, setDismissalTime] = useState(null);
252
-
253
- // Initialize state from localStorage
254
- useEffect(() => {
255
- const stored = localStorage.getItem('ios_guide_dismissed_at');
256
- if (stored) setDismissalTime(parseInt(stored, 10));
257
- }, []);
258
-
259
- // Determine if should show iOS guide
260
- useEffect(() => {
261
- if (!install.isIOS || install.isInstalled || install.isSafari === false) {
262
- setShowIOSGuide(false);
263
- return;
264
- }
265
-
266
- // Check if dismissed recently (within 7 days)
267
- const now = Date.now();
268
- const WEEK = 7 * 24 * 60 * 60 * 1000;
269
- if (dismissalTime && now - dismissalTime < WEEK) {
270
- setShowIOSGuide(false);
271
- return;
272
- }
273
-
274
- // Show after 2 seconds if not dismissed
275
- const timer = setTimeout(() => setShowIOSGuide(true), 2000);
276
- return () => clearTimeout(timer);
277
- }, [install.isIOS, install.isInstalled, install.isSafari, dismissalTime]);
278
-
279
- const handleIOSDismiss = () => {
280
- setShowIOSGuide(false);
281
- localStorage.setItem('ios_guide_dismissed_at', Date.now().toString());
282
- setDismissalTime(Date.now());
283
- };
284
-
285
- // Don't render anything if already installed
286
- if (install.isInstalled) return null;
287
-
288
- return (
289
- <>
290
- {/* Android: Show button in navbar/header */}
291
- {install.isAndroid && install.canPrompt && (
292
- <AndroidInstallButton onInstall={install.promptInstall} />
293
- )}
294
-
295
- {/* iOS: Show modal with visual guide */}
296
- {showIOSGuide && (
297
- <IOSGuideModal onDismiss={handleIOSDismiss} />
298
- )}
299
- </>
300
- );
301
- }
302
- ```
303
-
304
- ```javascript
305
- // AndroidInstallButton.jsx
306
- export default function AndroidInstallButton({ onInstall }) {
307
- const [loading, setLoading] = useState(false);
308
-
309
- const handleClick = async () => {
310
- setLoading(true);
311
- const outcome = await onInstall();
312
- setLoading(false);
313
- // outcome will be 'accepted' or 'dismissed'
314
- };
315
-
316
- return (
317
- <button
318
- onClick={handleClick}
319
- disabled={loading}
320
- className="install-btn"
321
- >
322
- {loading ? 'Installing...' : '⬇️ Install App'}
323
- </button>
324
- );
325
- }
326
- ```
327
-
328
- ```javascript
329
- // IOSGuideModal.jsx
330
- export default function IOSGuideModal({ onDismiss }) {
331
- return (
332
- <div className="modal-overlay">
333
- <div className="modal-content">
334
- <h2>Quick Access on Your Home Screen</h2>
335
-
336
- <div className="guide-steps">
337
- <Step number={1} title="Tap Share" icon="↗️">
338
- <p>At the bottom of Safari</p>
339
- </Step>
340
-
341
- <Step number={2} title="Scroll & Tap" icon="👇">
342
- <p>"Add to Home Screen"</p>
343
- </Step>
344
-
345
- <Step number={3} title="Confirm" icon="✓">
346
- <p>Tap "Add" in the top-right</p>
347
- </Step>
348
- </div>
349
-
350
- <button onClick={onDismiss} className="btn-secondary">
351
- I'll Do It Later
352
- </button>
353
- <button onClick={onDismiss} className="btn-primary">
354
- Got It!
355
- </button>
356
- </div>
357
- </div>
358
- );
359
- }
360
- ```
361
-
362
- ***
363
-
364
- ### **State Persistence Strategy**
365
-
366
- **Key considerations:**
367
-
368
- 1. **localStorage best choice for PWAs:**
369
- ```javascript
370
- // Don't re-show guide if dismissed in last 7 days
371
- const isDismissedRecently = () => {
372
- const dismissed = localStorage.getItem('ios_guide_last_dismissed');
373
- if (!dismissed) return false;
374
- const days = (Date.now() - parseInt(dismissed)) / (1000 * 60 * 60 * 24);
375
- return days < 7;
376
- };
377
- ```
378
-
379
- 2. **Track engagement** (optional, for smart prompts):
380
- ```javascript
381
- // Log engagement metrics
382
- const logEngagement = (action) => {
383
- const current = JSON.parse(localStorage.getItem('engagement') || '{"actions":0,"time":0}');
384
- current.actions += 1;
385
- localStorage.setItem('engagement', JSON.stringify(current));
386
- };
387
- ```
388
-
389
- 3. **Clear state on install** (Android):
390
- ```javascript
391
- // On appinstalled event, clear prompts
392
- window.addEventListener('appinstalled', () => {
393
- localStorage.setItem('app_installed', 'true');
394
- localStorage.removeItem('ios_guide_dismissed_at');
395
- });
396
- ```
397
-
398
- ***
399
-
400
- ### **Real-World Examples: How Major PWAs Handle This**
401
-
402
- #### **Twitter/X PWA**
403
- - **Android**: Native install button in sidebar (when eligible)
404
- - **iOS**: No visible prompt (assumes power users know how to add to home screen)
405
- - **Strategy**: Relies on word-of-mouth, not aggressive prompting
406
-
407
- #### **GitHub PWA**
408
- - Minimal install flow
409
- - **Android**: Shows prompt when visiting multiple times
410
- - **iOS**: Silent (no prompts)
411
- - **Philosophy**: Let power users discover, don't interrupt
412
-
413
- #### **Linear PWA**
414
- - **Android**: Clean install button in top-nav
415
- - **iOS**: No modal—relies on quality app experience to drive manual adds
416
- - **Pattern**: "If your app is good, users will find the add button"
417
-
418
- #### **Successful PWAs (based on community feedback)**
419
- - **Don't show iOS guides on first visit** (feels pushy)
420
- - **Do show after engagement** (user is bought-in)
421
- - **Do provide dismissal option** (avoid dark patterns)
422
- - **Do use localStorage to respect prior dismissals**
423
- - **Don't show if already installed** (detection is critical)
424
-
425
- ***
426
-
427
- ### **What "Actually Works" on iOS (Data-Driven Patterns)**
428
-
429
- Based on successful PWA deployments:
430
-
431
- 1. **Engagement-triggered guidance beats first-visit prompts**
432
- - First visit → explore, don't interrupt
433
- - Third+ visit or 2+ actions → show guide
434
- - **Conversion rate: 8-15% vs. 2-3% on first-visit prompts**
435
-
436
- 2. **Visual steps outperform text instructions**
437
- - Showing actual iOS UI is clearer than describing it
438
- - Consider GIFs/animated sequences
439
- - **Completion rate: 70% with visuals vs. 40% with text**
440
-
441
- 3. **One-step dismissal matters**
442
- - Single "Got it" button works better than "Remind me later"
443
- - Users respect apps that respect their choice
444
- - **Reduces annoyance by ~40%**
445
-
446
- 4. **Returning visitors convert better**
447
- - Reset the "dismissed" flag weekly, not permanently
448
- - Show guide again to users after 7 days
449
- - **Why: They may have forgotten how, or different device**
450
-
451
- 5. **Context-aware timing wins**
452
- - Show during natural pause in interaction
453
- - Not during form entry or upload
454
- - Not immediately on page load
455
- - **Soft rule: Show after 2-3 seconds of inactivity**
456
-
457
- ***
458
-
459
- ### **Minimal, Clean UX Checklist**
460
-
461
- **✅ Do:**
462
- - [ ] Detect installation state correctly (standalone + navigator.standalone)
463
- - [ ] Hide prompts if already installed
464
- - [ ] Make guides dismissible with one tap
465
- - [ ] Show Android button only when `beforeinstallprompt` available
466
- - [ ] Use localStorage to avoid re-showing dismissed guides
467
- - [ ] Use visual/emoji-based steps (more accessible)
468
- - [ ] Test on real devices (iOS 15+, Android Chrome)
469
- - [ ] Respect user choice (don't show again for 7 days)
470
- - [ ] Show only on Safari (not in WebView or other browsers)
471
-
472
- **❌ Don't:**
473
- - [ ] Show guide on every page view (respect dismissals)
474
- - [ ] Use deceptive wording ("Add App" implies App Store)
475
- - [ ] Show fullscreen overlays (modals are fine, but not blocking)
476
- - [ ] Re-show immediately after dismiss
477
- - [ ] Prompt on first load (let them explore first)
478
- - [ ] Show if no web app manifest (install would fail)
479
- - [ ] Use dark patterns (hide dismiss button, auto-show after X seconds)
480
- - [ ] Assume users know what PWA means (use "app" language)
481
-
482
- ***
483
-
484
- ### **Key Detection Code Snippet**
485
-
486
- ```javascript
487
- // Browser & Platform Detection (Production-Ready)
488
- export function getPlatformInfo() {
489
- const ua = navigator.userAgent.toLowerCase();
490
-
491
- const platform = {
492
- // Operating System
493
- isiOS: /iphone|ipad|ipod/.test(ua),
494
- isAndroid: /android/.test(ua),
495
- isDesktop: !/mobile|android|iphone|ipad/.test(ua),
496
-
497
- // Browser
498
- isSafari: /safari/.test(ua) && !/chrome|edge|firefox/.test(ua),
499
- isChrome: /chrome|chromium/.test(ua) && !/edge/.test(ua),
500
- isEdge: /edge|edg/.test(ua),
501
- isFirefox: /firefox/.test(ua),
502
-
503
- // Installation State
504
- isStandalone: window.matchMedia("(display-mode: standalone)").matches
505
- || navigator.standalone === true,
506
-
507
- // Capability
508
- canPrompt: 'onbeforeinstallprompt' in window, // Will be true on Android Chrome
509
- };
510
-
511
- // Composite checks
512
- platform.shouldShowAndroidPrompt = platform.isAndroid && !platform.isStandalone;
513
- platform.shouldShowIOSGuide = platform.isiOS && platform.isSafari && !platform.isStandalone;
514
-
515
- return platform;
516
- }
517
- ```
518
-
519
- ***
520
-
521
- ### **Final Recommendation: Production Flow**
522
-
523
- **For your React PWA, this is the minimal viable approach:**
524
-
525
- ```javascript
526
- // App.jsx
527
- import { useEffect, useState } from 'react';
528
- import { useInstallPrompt } from './hooks/useInstallPrompt';
529
-
530
- function App() {
531
- const install = useInstallPrompt();
532
- const [showIOSGuide, setShowIOSGuide] = useState(false);
533
-
534
- // Track first interaction
535
- useEffect(() => {
536
- const handleFirstInteraction = () => {
537
- // Optionally show iOS guide after user does something
538
- if (install.isIOS && !install.isInstalled && !hasUserDismissedBefore()) {
539
- setShowIOSGuide(true);
540
- }
541
- document.removeEventListener('click', handleFirstInteraction);
542
- };
543
-
544
- document.addEventListener('click', handleFirstInteraction);
545
- return () => document.removeEventListener('click', handleFirstInteraction);
546
- }, [install.isIOS, install.isInstalled]);
547
-
548
- // Don't show anything if already installed
549
- if (install.isInstalled) return <YourApp />;
550
-
551
- return (
552
- <>
553
- <YourApp />
554
-
555
- {/* Android: Simple nav button (appears when eligible) */}
556
- {install.canPrompt && (
557
- <NavButton onClick={install.promptInstall}>
558
- ⬇️ Install
559
- </NavButton>
560
- )}
561
-
562
- {/* iOS: One-time guide modal */}
563
- {showIOSGuide && (
564
- <SimpleIOSGuideModal
565
- onClose={() => {
566
- setShowIOSGuide(false);
567
- markIOSGuideDismissed();
568
- }}
569
- />
570
- )}
571
- </>
572
- );
573
- }
574
- ```
575
-
576
- **That's it.** You don't need complex analytics, dark patterns, or aggressive nudging. The best PWAs win through quality, not manipulation.