docs: add Trellis planning and project specs
This commit is contained in:
34
.trellis/spec/big-question/index.md
Normal file
34
.trellis/spec/big-question/index.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Common Issues and Solutions
|
||||
|
||||
> Documented pitfalls discovered while building production Next.js fullstack applications.
|
||||
> These issues apply to any project using Next.js with PostgreSQL, Drizzle ORM, Tailwind CSS, and i18n.
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Level | Description |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| Critical | Build fails or data corruption |
|
||||
| Warning | Degraded experience, workaround exists |
|
||||
| Info | Minor visual issue, easy to fix once identified |
|
||||
|
||||
---
|
||||
|
||||
## Issue Index
|
||||
|
||||
| Issue | Category | Severity |
|
||||
| ---------------------------------------------------------------- | ---------------- | -------- |
|
||||
| [postgres-json-jsonb.md](./postgres-json-jsonb.md) | Database/ORM | Critical |
|
||||
| [sentry-nextintl-conflict.md](./sentry-nextintl-conflict.md) | Plugin Conflicts | Critical |
|
||||
| [turbopack-webpack-flexbox.md](./turbopack-webpack-flexbox.md) | Build System | Warning |
|
||||
| [webkit-tap-highlight.md](./webkit-tap-highlight.md) | Mobile/CSS | Info |
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Found a new pitfall? Add it to this directory:
|
||||
|
||||
1. Create a new `.md` file with a descriptive kebab-case name
|
||||
2. Follow the existing format: Problem, Root Cause, Solution, Key Takeaways
|
||||
3. Update this index table with the correct category and severity
|
||||
4. Include reproducible code examples whenever possible
|
||||
192
.trellis/spec/big-question/postgres-json-jsonb.md
Normal file
192
.trellis/spec/big-question/postgres-json-jsonb.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# PostgreSQL JSON vs JSONB Type Issues with Drizzle ORM
|
||||
|
||||
## Problem
|
||||
|
||||
Database queries using PostgreSQL's `jsonb_*` functions fail with type errors, even though the column appears to store JSON data correctly.
|
||||
|
||||
**Error message:**
|
||||
```
|
||||
function jsonb_array_elements(json) does not exist
|
||||
HINT: No function matches the given name and argument types.
|
||||
You might need to add explicit type casts.
|
||||
```
|
||||
|
||||
**Additional symptoms:**
|
||||
- Raw SQL queries fail to find columns with camelCase names
|
||||
- JSON aggregation functions return unexpected results
|
||||
|
||||
## Root Cause
|
||||
|
||||
### Issue 1: Drizzle's `json()` Maps to PostgreSQL `json`, Not `jsonb`
|
||||
|
||||
When defining a JSON column in Drizzle ORM:
|
||||
|
||||
```typescript
|
||||
// Drizzle schema definition
|
||||
export const orders = pgTable("orders", {
|
||||
id: text("id").primaryKey(),
|
||||
metadata: json("metadata"), // Creates PostgreSQL 'json' type, NOT 'jsonb'
|
||||
});
|
||||
```
|
||||
|
||||
PostgreSQL has two JSON types with different characteristics:
|
||||
|
||||
| Feature | `json` | `jsonb` |
|
||||
|---------|--------|---------|
|
||||
| Storage | Text (preserves whitespace, key order) | Binary (normalized) |
|
||||
| Functions | `json_*` functions only | `jsonb_*` functions only |
|
||||
| Indexing | Limited | GIN indexes supported |
|
||||
| Performance | Slower for operations | Faster for operations |
|
||||
|
||||
The `jsonb_*` functions (like `jsonb_array_elements`, `jsonb_extract_path`) **only work with `jsonb` type**.
|
||||
|
||||
### Issue 2: Column Name Case Sensitivity
|
||||
|
||||
PostgreSQL treats unquoted identifiers as lowercase. If your column uses camelCase:
|
||||
|
||||
```sql
|
||||
-- This fails (looks for column named 'metadata' in lowercase)
|
||||
SELECT metadata->>'userId' FROM orders;
|
||||
|
||||
-- Column is actually named "metaData" with exact case
|
||||
SELECT "metaData"->>'userId' FROM orders;
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Use Type Cast for jsonb Functions
|
||||
|
||||
Add `::jsonb` type cast before using jsonb functions:
|
||||
|
||||
```typescript
|
||||
// Before (fails)
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(items) as item
|
||||
FROM orders
|
||||
WHERE id = ${orderId}
|
||||
`);
|
||||
|
||||
// After (works)
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(items::jsonb) as item
|
||||
FROM orders
|
||||
WHERE id = ${orderId}
|
||||
`);
|
||||
```
|
||||
|
||||
### Solution 2: Define Column as `jsonb` in Schema
|
||||
|
||||
If you need jsonb functionality frequently, define the column as jsonb:
|
||||
|
||||
```typescript
|
||||
import { pgTable, text, jsonb } from "drizzle-orm/pg-core";
|
||||
|
||||
export const orders = pgTable("orders", {
|
||||
id: text("id").primaryKey(),
|
||||
metadata: jsonb("metadata"), // Now uses PostgreSQL 'jsonb' type
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** This requires a migration if the column already exists.
|
||||
|
||||
### Solution 3: Quote camelCase Column Names in Raw SQL
|
||||
|
||||
Always use double quotes for camelCase column names:
|
||||
|
||||
```typescript
|
||||
// Before (fails - column not found)
|
||||
const result = await db.execute(sql`
|
||||
SELECT "userId", createdAt
|
||||
FROM orders
|
||||
`);
|
||||
|
||||
// After (works)
|
||||
const result = await db.execute(sql`
|
||||
SELECT "userId", "createdAt"
|
||||
FROM orders
|
||||
`);
|
||||
```
|
||||
|
||||
### Complete Example: Querying JSON Array Data
|
||||
|
||||
```typescript
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// Table with json column storing an array of items
|
||||
// items: [{ "productId": "123", "quantity": 2 }, ...]
|
||||
|
||||
// Query to find orders containing a specific product
|
||||
async function findOrdersWithProduct(productId: string) {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
o.id,
|
||||
o."createdAt",
|
||||
item->>'productId' as "productId",
|
||||
(item->>'quantity')::int as quantity
|
||||
FROM orders o,
|
||||
jsonb_array_elements(o.items::jsonb) as item
|
||||
WHERE item->>'productId' = ${productId}
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Query to aggregate JSON array data
|
||||
async function getOrderItemStats(orderId: string) {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
COUNT(*) as "itemCount",
|
||||
SUM((item->>'quantity')::int) as "totalQuantity"
|
||||
FROM orders o,
|
||||
jsonb_array_elements(o.items::jsonb) as item
|
||||
WHERE o.id = ${orderId}
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practice: Create a Helper for JSON Queries
|
||||
|
||||
```typescript
|
||||
// utils/db-helpers.ts
|
||||
import { sql, SQL } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Wraps a column reference with ::jsonb cast for use with jsonb functions
|
||||
*/
|
||||
export function asJsonb(column: SQL | string): SQL {
|
||||
if (typeof column === "string") {
|
||||
return sql.raw(`"${column}"::jsonb`);
|
||||
}
|
||||
return sql`${column}::jsonb`;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(${asJsonb("items")}) as item
|
||||
FROM orders
|
||||
`);
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Know the difference between `json` and `jsonb`**
|
||||
- `json`: Text storage, use `json_*` functions
|
||||
- `jsonb`: Binary storage, use `jsonb_*` functions, better performance
|
||||
|
||||
2. **Drizzle's `json()` creates PostgreSQL `json` type** - use `jsonb()` if you need jsonb functionality
|
||||
|
||||
3. **Always add `::jsonb` cast** when using jsonb functions with json columns
|
||||
|
||||
4. **Quote camelCase identifiers** in raw SQL queries with double quotes
|
||||
|
||||
5. **Prefer Drizzle's query builder** over raw SQL when possible to avoid these issues
|
||||
|
||||
6. **Test raw SQL queries** directly in a PostgreSQL client before using in code
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [PostgreSQL JSON Types Documentation](https://www.postgresql.org/docs/current/datatype-json.html)
|
||||
- [PostgreSQL JSON Functions](https://www.postgresql.org/docs/current/functions-json.html)
|
||||
- [Drizzle ORM PostgreSQL Column Types](https://orm.drizzle.team/docs/column-types/pg)
|
||||
223
.trellis/spec/big-question/sentry-nextintl-conflict.md
Normal file
223
.trellis/spec/big-question/sentry-nextintl-conflict.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Sentry and next-intl Build Configuration Conflict
|
||||
|
||||
## Problem
|
||||
|
||||
Production builds fail with the error:
|
||||
|
||||
```
|
||||
Error: Couldn't find next-intl config file
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
Error: Failed to collect page data for /[locale]/page
|
||||
```
|
||||
|
||||
The build works fine in development mode but fails during `next build`.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `withSentryConfig` wrapper in `next.config.js` interferes with other Next.js plugins, particularly `next-intl`'s plugin (`createNextIntlPlugin`).
|
||||
|
||||
### Technical Details
|
||||
|
||||
When you chain multiple config wrappers:
|
||||
|
||||
```javascript
|
||||
// next.config.js - Problematic configuration
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// This chaining causes conflicts
|
||||
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||
// Sentry options
|
||||
});
|
||||
```
|
||||
|
||||
The issue occurs because:
|
||||
|
||||
1. **Plugin execution order matters** - Sentry's wrapper modifies the webpack configuration in ways that can break other plugins' assumptions
|
||||
2. **Build-time vs runtime** - Some plugins expect to run at specific build phases
|
||||
3. **Config mutation** - Wrappers may mutate the config object in incompatible ways
|
||||
|
||||
Specifically, `withSentryConfig`:
|
||||
- Modifies webpack configuration extensively
|
||||
- Adds custom loaders and plugins
|
||||
- May interfere with `next-intl`'s message loading mechanism
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Remove withSentryConfig Wrapper (Recommended)
|
||||
|
||||
Sentry's runtime features still work via `instrumentation.ts` without the config wrapper:
|
||||
|
||||
```javascript
|
||||
// next.config.js - Fixed configuration
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// Only use next-intl wrapper, no Sentry wrapper
|
||||
export default withNextIntl(nextConfig);
|
||||
```
|
||||
|
||||
**Why Sentry still works:**
|
||||
|
||||
The `withSentryConfig` wrapper is primarily for:
|
||||
- Source map uploading
|
||||
- Build-time instrumentation
|
||||
- Release management
|
||||
|
||||
However, Sentry's core error tracking works through `instrumentation.ts`:
|
||||
|
||||
```typescript
|
||||
// instrumentation.ts
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
// ... other options
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: Use Sentry Without Source Maps
|
||||
|
||||
If you need some Sentry build features but want to avoid conflicts:
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// Apply next-intl first
|
||||
const configWithIntl = withNextIntl(nextConfig);
|
||||
|
||||
// Conditionally apply Sentry only if not causing issues
|
||||
const finalConfig = process.env.SKIP_SENTRY_BUILD
|
||||
? configWithIntl
|
||||
: withSentryConfig(configWithIntl, {
|
||||
silent: true,
|
||||
disableSourceMapUpload: true, // Disable problematic feature
|
||||
});
|
||||
|
||||
export default finalConfig;
|
||||
```
|
||||
|
||||
### Solution 3: Alternative Plugin Order
|
||||
|
||||
Sometimes reversing the wrapper order helps:
|
||||
|
||||
```javascript
|
||||
// Try applying Sentry first, then next-intl
|
||||
const configWithSentry = withSentryConfig(nextConfig, sentryOptions);
|
||||
export default withNextIntl(configWithSentry);
|
||||
```
|
||||
|
||||
**Note:** This may or may not work depending on your specific versions.
|
||||
|
||||
### Solution 4: Separate Sentry Configuration
|
||||
|
||||
Use Sentry CLI for source map upload instead of the webpack plugin:
|
||||
|
||||
```bash
|
||||
# In your CI/CD pipeline after build
|
||||
npx @sentry/cli sourcemaps upload ./next/static --org your-org --project your-project
|
||||
```
|
||||
|
||||
```javascript
|
||||
// next.config.js - Clean configuration
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
productionBrowserSourceMaps: true, // Enable source maps for Sentry CLI
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
```
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After applying the fix:
|
||||
|
||||
1. **Clean build artifacts:**
|
||||
```bash
|
||||
rm -rf .next node_modules/.cache
|
||||
```
|
||||
|
||||
2. **Test production build:**
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. **Verify Sentry works in production:**
|
||||
```bash
|
||||
pnpm start
|
||||
# Trigger a test error and check Sentry dashboard
|
||||
```
|
||||
|
||||
4. **Test i18n routing:**
|
||||
```bash
|
||||
# Visit different locale routes
|
||||
curl http://localhost:3000/en/page
|
||||
curl http://localhost:3000/de/page
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Plugin wrappers can conflict** - Be cautious when combining multiple Next.js config wrappers
|
||||
|
||||
2. **Sentry works without withSentryConfig** - Core error tracking functions via `instrumentation.ts`
|
||||
|
||||
3. **Order matters** - Try different wrapper orders if you must use multiple plugins
|
||||
|
||||
4. **Source maps are optional** - You can still get stack traces without source map upload
|
||||
|
||||
5. **Test builds locally** - Always run `pnpm build` before deploying
|
||||
|
||||
6. **Keep dependencies updated** - Plugin compatibility issues are often fixed in newer versions
|
||||
|
||||
## Version Information
|
||||
|
||||
This issue was observed with:
|
||||
- Next.js 14.x / 15.x
|
||||
- @sentry/nextjs 7.x / 8.x
|
||||
- next-intl 3.x
|
||||
|
||||
Check the respective changelogs for compatibility updates.
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Sentry Next.js SDK Documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/)
|
||||
- [next-intl Plugin Documentation](https://next-intl-docs.vercel.app/docs/getting-started/app-router)
|
||||
- [Next.js Configuration](https://nextjs.org/docs/app/api-reference/next-config-js)
|
||||
141
.trellis/spec/big-question/turbopack-webpack-flexbox.md
Normal file
141
.trellis/spec/big-question/turbopack-webpack-flexbox.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Turbopack vs Webpack Flexbox Layout Differences
|
||||
|
||||
## Problem
|
||||
|
||||
Layout works correctly in development mode (Turbopack) but breaks in production (Webpack). Specifically, flex containers and their children behave differently between the two bundlers.
|
||||
|
||||
**Symptoms:**
|
||||
- Components have correct height in dev mode but collapse or overflow in production
|
||||
- Scrollable areas work in dev but fail in prod
|
||||
- Nested flex layouts display differently between environments
|
||||
|
||||
## Root Cause
|
||||
|
||||
Turbopack (Next.js dev mode default) and Webpack (production build) have subtle differences in how they process CSS, particularly regarding flexbox behavior:
|
||||
|
||||
1. **Turbopack is stricter** about explicit flexbox properties
|
||||
2. **Webpack may auto-infer** certain flex child behaviors that Turbopack does not
|
||||
3. The difference lies in how CSS is compiled and applied, not in the CSS specification itself
|
||||
|
||||
### Technical Details
|
||||
|
||||
When a flex container has `flex-direction: column` and children that need to fill available space, the behavior depends on:
|
||||
|
||||
- The `align-items` property (defaults to `stretch` but may not be consistently applied)
|
||||
- Whether children have explicit `height` or `flex` properties
|
||||
- The interaction between nested flex containers
|
||||
|
||||
**Example of problematic layout:**
|
||||
|
||||
```tsx
|
||||
// Parent component
|
||||
<div className="flex flex-col h-screen">
|
||||
<Header /> {/* Fixed height */}
|
||||
<main className="flex-1 flex"> {/* Should fill remaining space */}
|
||||
<Sidebar />
|
||||
<Content /> {/* Should scroll internally */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
In Turbopack, the `main` element might not properly pass its height to children without explicit `items-stretch`.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Explicitly Set `items-stretch` on Flex Containers
|
||||
|
||||
Add `items-stretch` to main flex containers that need children to fill available space:
|
||||
|
||||
```tsx
|
||||
// Before (inconsistent between Turbopack/Webpack)
|
||||
<div className="flex flex-col h-screen">
|
||||
<main className="flex-1 flex">
|
||||
{/* children */}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
// After (consistent behavior)
|
||||
<div className="flex flex-col h-screen items-stretch">
|
||||
<main className="flex-1 flex items-stretch">
|
||||
{/* children */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Apply Parent/Child Responsibility Separation
|
||||
|
||||
Follow a clear pattern for layout responsibilities:
|
||||
|
||||
**Parent's Responsibility:**
|
||||
- Define the flex container (`flex`, `flex-col`, `flex-row`)
|
||||
- Set alignment (`items-stretch`, `justify-between`)
|
||||
- Control overall dimensions (`h-screen`, `w-full`)
|
||||
|
||||
**Child's Responsibility:**
|
||||
- Define its own flex behavior (`flex-1`, `flex-shrink-0`)
|
||||
- Handle internal overflow (`overflow-auto`, `overflow-hidden`)
|
||||
- Set min/max constraints (`min-h-0`, `max-w-full`)
|
||||
|
||||
### 3. Use `min-h-0` for Scrollable Flex Children
|
||||
|
||||
When a flex child needs internal scrolling:
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col h-full items-stretch">
|
||||
<div className="flex-shrink-0">Fixed Header</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{/* Scrollable content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The `min-h-0` is crucial because flex items default to `min-height: auto`, which can prevent overflow from working correctly.
|
||||
|
||||
### Complete Example
|
||||
|
||||
```tsx
|
||||
// App layout with consistent dev/prod behavior
|
||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen items-stretch">
|
||||
{/* Fixed navigation */}
|
||||
<nav className="flex-shrink-0 h-16 border-b">
|
||||
<Navigation />
|
||||
</nav>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex items-stretch min-h-0">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0 border-r overflow-auto">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Main content with internal scroll */}
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Always test production builds locally** before deployment using `pnpm build && pnpm start`
|
||||
|
||||
2. **Be explicit with flexbox properties** - Don't rely on browser defaults or bundler behavior
|
||||
|
||||
3. **Use `items-stretch` explicitly** on containers where children need to fill space
|
||||
|
||||
4. **Remember `min-h-0` and `min-w-0`** for scrollable flex children
|
||||
|
||||
5. **Separate layout responsibilities** between parent (container behavior) and child (self behavior)
|
||||
|
||||
6. **Document layout patterns** in your project to ensure consistency across the team
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [CSS Flexbox Guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
|
||||
- [Next.js Turbopack Documentation](https://nextjs.org/docs/architecture/turbopack)
|
||||
- [Tailwind CSS Flexbox Utilities](https://tailwindcss.com/docs/flex)
|
||||
218
.trellis/spec/big-question/webkit-tap-highlight.md
Normal file
218
.trellis/spec/big-question/webkit-tap-highlight.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# WebKit Tap Highlight and Border-Radius Issues on Mobile
|
||||
|
||||
## Problem
|
||||
|
||||
Buttons and interactive elements lose their `border-radius` styling when tapped on mobile devices (iOS Safari, Chrome on iOS). The element briefly shows a rectangular highlight instead of respecting the rounded corners.
|
||||
|
||||
**Symptoms:**
|
||||
- Button appears with sharp corners during tap/touch
|
||||
- A blue or gray rectangular overlay flashes on touch
|
||||
- The visual glitch only occurs on WebKit-based mobile browsers
|
||||
- Desktop browsers and Android Chrome don't show the issue
|
||||
|
||||
## Root Cause
|
||||
|
||||
WebKit browsers apply a default tap highlight effect to interactive elements. This highlight:
|
||||
|
||||
1. **Ignores `border-radius`** - The highlight is applied as a simple rectangular overlay
|
||||
2. **Uses system default color** - Typically a semi-transparent blue or gray
|
||||
3. **Overrides visual styling** - The highlight appears on top of your custom styles
|
||||
|
||||
### Technical Details
|
||||
|
||||
When you tap an element on iOS Safari:
|
||||
|
||||
```css
|
||||
/* WebKit's default behavior (pseudo-representation) */
|
||||
element:active {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
/* This creates a RECTANGULAR overlay, ignoring border-radius */
|
||||
}
|
||||
```
|
||||
|
||||
The tap highlight is rendered as a separate layer that doesn't respect the element's `border-radius`, `clip-path`, or other shape-defining properties.
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Disable Tap Highlight + Wrapper with Overflow Hidden
|
||||
|
||||
The most reliable solution combines two techniques:
|
||||
|
||||
```tsx
|
||||
// Button component with proper mobile touch handling
|
||||
function Button({ children, className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden inline-block">
|
||||
<button
|
||||
className={cn("rounded-lg px-4 py-2 bg-blue-500 text-white", className)}
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
1. `WebkitTapHighlightColor: "transparent"` removes the default highlight
|
||||
2. The wrapper `div` with `overflow-hidden` clips any remaining visual artifacts
|
||||
3. Both elements have matching `border-radius` for consistent appearance
|
||||
|
||||
### Solution 2: CSS-Only Approach
|
||||
|
||||
If you can't modify the component structure:
|
||||
|
||||
```css
|
||||
/* In your global CSS */
|
||||
.tap-safe {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom active state to replace the highlight */
|
||||
.tap-safe:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
<button className="tap-safe rounded-lg px-4 py-2 bg-blue-500">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Solution 3: Tailwind CSS Utility Class
|
||||
|
||||
Add a reusable utility in your Tailwind config:
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
function({ addUtilities }) {
|
||||
addUtilities({
|
||||
'.tap-highlight-none': {
|
||||
'-webkit-tap-highlight-color': 'transparent',
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Then use it in components:
|
||||
|
||||
```tsx
|
||||
<button className="tap-highlight-none rounded-lg px-4 py-2">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Solution 4: Wrapper Component for Consistent Behavior
|
||||
|
||||
Create a reusable wrapper for all interactive rounded elements:
|
||||
|
||||
```tsx
|
||||
// components/ui/touch-safe-wrapper.tsx
|
||||
interface TouchSafeWrapperProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
borderRadius?: string;
|
||||
}
|
||||
|
||||
export function TouchSafeWrapper({
|
||||
children,
|
||||
className,
|
||||
borderRadius = "rounded-lg"
|
||||
}: TouchSafeWrapperProps) {
|
||||
return (
|
||||
<div className={cn(borderRadius, "overflow-hidden inline-flex", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<TouchSafeWrapper>
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 bg-blue-500"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
>
|
||||
Click me
|
||||
</button>
|
||||
</TouchSafeWrapper>
|
||||
```
|
||||
|
||||
### Complete Example: Card with Clickable Areas
|
||||
|
||||
```tsx
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3>{product.name}</h3>
|
||||
<p>{product.description}</p>
|
||||
|
||||
{/* Action buttons with tap-safe handling */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 bg-blue-500 text-white"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 border border-gray-300"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => viewDetails(product)}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **WebKit tap highlight ignores border-radius** - This is browser behavior, not a CSS bug
|
||||
|
||||
2. **Always set `WebkitTapHighlightColor: "transparent"`** on interactive elements with rounded corners
|
||||
|
||||
3. **Use a wrapper with `overflow-hidden`** for the most reliable visual clipping
|
||||
|
||||
4. **Test on actual iOS devices** - Simulators and browser dev tools may not reproduce the issue
|
||||
|
||||
5. **Consider adding custom active states** to replace the removed tap feedback for better UX
|
||||
|
||||
6. **Create reusable components** that handle mobile touch behavior consistently
|
||||
|
||||
## Browser Support Notes
|
||||
|
||||
| Browser | Needs Fix |
|
||||
|---------|-----------|
|
||||
| iOS Safari | Yes |
|
||||
| Chrome on iOS | Yes (uses WebKit) |
|
||||
| Firefox on iOS | Yes (uses WebKit) |
|
||||
| Android Chrome | Usually no |
|
||||
| Desktop browsers | No |
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MDN: -webkit-tap-highlight-color](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color)
|
||||
- [WebKit Bug Tracker](https://bugs.webkit.org/)
|
||||
- [CSS Tricks: Handling Touch Events](https://css-tricks.com/snippets/css/remove-gray-highlight-when-tapping-links-in-mobile-safari/)
|
||||
Reference in New Issue
Block a user