Web Vibration API: Haptic Feedback on Android and iPhone
TL;DR
As of October 2026 the Web Vibration API (navigator.vibrate) works only in Chromium browsers on Android, only after the user has tapped the page, and with no control over intensity; Safari on iPhone has never shipped it. The one haptic a page can trigger on an iPhone is the system tick of a native HTML switch, and since iOS 26.5 WebKit plays it only when the user's own tap toggles the switch, never for a click from JavaScript. Put navigator.vibrate behind one helper that returns false instead of throwing, lay a label for a hidden switch over buttons that should tick on iPhone, and never let a tick be the only feedback.
You want a tap to tick under the thumb the way it does in a native app, so you reach for the web vibration API. On an Android phone navigator.vibrate(10) works on the first try. On an iPhone nothing happens, on a laptop the call returns true and nothing happens, and the iPhone workaround you found on GitHub stopped working in iOS 26.5. This tutorial maps what a page can really trigger in October 2026, builds a helper that stays silent wherever it cannot play, and wires haptics into a switch, a button and a wheel picker in React.
What can the Web Vibration API do in 2026?
It can switch the vibration motor on and off for durations you give in milliseconds, in Chromium browsers on Android, after the user has tapped the page. That is the whole API: one method, navigator.vibrate, that takes a number or an array of alternating vibrate and pause times. There is no intensity, no waveform and no way to ask whether the device has a motor at all. Software Mansion's August 2026 overview of web haptics sums up the support in one line: "the vibration API is effectively limited to Chromium-based browsers."
Here is where a tick can reach a finger, as of October 2026 (caniuse for support, our own test in desktop Chromium for the last row):
| Where the page runs | navigator.vibrate | What the user feels |
| --- | --- | --- |
| Chrome or Samsung Internet on Android | supported | the pattern you pass, once the page has seen a tap |
| Firefox | removed in version 129, not supported on Android | nothing |
| Safari on iPhone, and home screen web apps | never shipped | the system tick of a native switch, only when the user's own tap toggles it |
| Chrome on a desktop | present, returns true | nothing, there is no motor |
The last row is the trap. Feature detection with "vibrate" in navigator says yes in desktop Chrome, so it tells you nothing about hardware. The boolean that navigator.vibrate() returns only says the browser accepted the pattern; it says nothing about whether anyone felt it.
How does navigator.vibrate behave on Android?
It plays the pattern you pass and replaces any pattern already running, within limits you can read in Chromium's vibration controller:
navigator.vibrate(8); // one 8ms pulse: a light tick
navigator.vibrate([10, 60, 16]); // pulse, pause, pulse: a success pattern
navigator.vibrate(0); // cancels whatever is running (so does [])
- It needs a tap first. MDN lists sticky user activation as a requirement. Until the page has seen a tap, a click or a key press, Chrome returns false and logs "Blocked call to navigator.vibrate because user hasn't tapped on the frame or any embedded frame yet". Once granted, the activation lasts until the page navigates, so a tick from a scroll handler or after an await works later on.
- It is silent in the background. The call returns false while the page is hidden.
- Long patterns are trimmed. Chromium caps each entry at 10,000ms, keeps the first 99 entries and drops a trailing pause.
- The phone can overrule you. MDN notes that some devices do not vibrate in silent or Do Not Disturb mode.
Keep the pulses short; the helper below uses 4 to 22ms per pulse. The 200ms buzz in many examples (MDN's first one included) reads as a notification, which is the wrong message for a toggle.
Does the Web Vibration API work on iOS?
No. Safari has never implemented navigator.vibrate, and caniuse lists no version of Safari on iOS with it, up to 27.2. Look up web vibration API iOS support and you land on one workaround: Safari 17.4 added a native switch control, <input type="checkbox" switch> (WebKit's announcement), and toggling it plays the system haptic tick.
Until iOS 26.5, libraries such as ios-haptics played that tick on demand by calling .click() on a hidden label wired to the switch. That no longer works. The ios-haptics README of April 2026 says the trick "only works on ios 17.4 to 26.4, as apple patched it in ios 26.5". The WebKit fix, merged into the main branch on May 21, 2026, closes that gap: WebKit already required user activation for the haptic, and now it also requires a trusted event, which a click forwarded from label.click() no longer is.
What still works is a real tap that lands on a label for the switch. The tap produces a trusted click, the label forwards it to the switch, and because the original click was trusted, the WebKit change still lets the switch tick. The ios-haptics source moved to exactly that on September 22, 2026: it lays a transparent label over your element. Here is the same idea as a React 19 component:
// components/ios-tap-tick.tsx
"use client";
// iOS plays the switch haptic only for a trusted click, so the user's own tap has to land on a label for the switch.
// put it inside a positioned button; it covers the button and the click still bubbles to the button's onClick
export function IOSTapTick() {
return (
<label
aria-hidden="true"
data-slot="ios-tap-tick"
className="absolute inset-0 touch-manipulation [-webkit-tap-highlight-color:transparent]"
>
<input
type="checkbox"
// react 19 passes the unknown attribute through; the spread keeps TypeScript quiet
{...{ switch: "" }}
tabIndex={-1}
// the label, not the switch, sits under the finger, so a swipe that starts here still scrolls the page
className="invisible absolute m-0 size-px"
// the label forwards the click to the switch: stop that second click before it reaches the button
onClick={(event) => event.stopPropagation()}
/>
</label>
);
}
Use it in buttons, not links: in our Chromium test a label inside an <a> ran the link's onClick but took over the default action, so the link never navigated. Leave the input without a name, so it never ends up in a form submission. Outside iOS a tap on it only toggles a hidden checkbox nobody sees, so it can render on every platform and never causes a hydration mismatch.
On an iPhone you get one fixed tick per tap, from the tap itself: no patterns, nothing after an await, nothing during a drag, and nothing for a wheel spinning past rows, because a scroll never produces a click.
How do you call navigator.vibrate safely where it is unsupported?
Check for the method itself, check activation, catch everything, and return a boolean. Here is the whole helper without dependencies:
// lib/vibrate.ts
export type HapticKind = "selection" | "light" | "medium" | "heavy" | "success" | "warning" | "error";
const PATTERNS: Record<HapticKind, number | number[]> = {
selection: 4,
light: 8,
medium: 14,
heavy: 22,
success: [10, 60, 16],
warning: [16, 70, 16],
error: [22, 50, 22, 50, 22],
};
let enabled = true;
export function setHapticsEnabled(value: boolean) {
enabled = value;
}
export function vibrate(kind: HapticKind = "light"): boolean {
if (!enabled) return false;
// node 21+ has a global navigator too, so test for the method, not for navigator
if (typeof navigator === "undefined" || typeof navigator.vibrate !== "function") return false;
// chrome blocks the call (and logs a warning) until the page has seen a tap
if (navigator.userActivation && !navigator.userActivation.hasBeenActive) return false;
try {
return navigator.vibrate(PATTERNS[kind]);
} catch {
return false; // feedback is never worth an exception
}
}
The Node check matters. Node 21 and later define a global navigator with no vibrate, so a helper that only checks typeof navigator gets past its guard on the server and calls a method that is not there.
Wingo UI's haptics helper is the same idea plus an iPhone fallback and a force option that plays a tick even when haptics are off. haptic(kind) returns whether a haptic was sent, configureHaptics({ enabled, iosFallback }) sets the app-wide options, and supportsHaptics() tells you whether the browser exposes a haptic path at all (call it in an effect: it is false on the server, and true in desktop Chrome for the reason above).
> Live demo: Haptics. The original article embeds an interactive demo here (On an Android phone, tap each tile to feel the seven kinds; the bars draw each vibration pattern and the line under the card shows what haptic() returned). Try it live on wingo-ui.com or open the Haptics docs. Install: npx wingo-ui@latest add haptics
A caveat about our own code: the helper's iosFallback option, on by default, still clicks a hidden label from script. That plays on iPhones up to iOS 26.4, is ignored from 26.5, and the call still returns true there because WebKit reports nothing back. If you add the tap overlay above, call configureHaptics({ iosFallback: false }) once so iPhones on older versions do not tick twice.
The snippets below import Wingo UI's helper. With the dependency-free file above, import vibrate from @/lib/vibrate instead and use setHapticsEnabled in place of configureHaptics; it has no force option.
Which haptic should each interaction get?
One tick per event, sized to the event. These are the patterns the helper sends on Android:
| Kind | Pattern (ms) | Use it for | Wingo UI component that plays it |
| --- | --- | --- | --- |
| selection | 4 | a row of a wheel or a slider step passing the center | Picker Wheel |
| light | 8 | a tap, a toggle | Haptic Button press |
| medium | 14 | a long press, a snap, a crossed threshold | Haptic Button long press, Pull To Refresh arming, Slide To Confirm armed |
| heavy | 22 | a committed full swipe, a drop | none; call it from your own drop handler |
| success | 10, 60, 16 | payment sent, item saved | Haptic Button hold confirmed, Slide To Confirm done |
| warning | 16, 70, 16 | a limit reached, a hold released too early | Haptic Button tap in hold mode |
| error | 22, 50, 22, 50, 22 | a wrong PIN, a failed save | Haptic Button rejected promise |
Three rules keep it from turning into noise. Call it from the handler of a real gesture, never from an effect or a render. Never tick per animation frame; tick when something changes state, like a row reaching the center. And never put information only in the tick: desktop users, iPhone users on a wheel and anyone with a phone on silent will not feel it, so the screen must say the same thing.
How do you add React haptic feedback to a switch, a button and a wheel?
Call haptic() in the handler where the state changes, once per change.
A switch
The free Switch calls onCheckedChange on every click, key or drag, and a returned promise makes it spin, then flip back with errorText if the save fails. Tick on the change and again on failure:
"use client";
import { Switch } from "@/components/ui/switch";
import { haptic } from "@/lib/haptics";
export function NewMessagesSetting({ save }: { save: (on: boolean) => Promise<void> }) {
return (
<Switch
label="New messages"
description="When someone writes to you about a listing"
labelPosition="start"
defaultChecked
onCheckedChange={async (on) => {
haptic("light");
try {
await save(on);
} catch (error) {
haptic("error");
throw error; // the switch flips back and shows errorText
}
}}
/>
);
}
On iOS 26.5 and later this Switch stays silent: it is a Radix button with role="switch", not the native input, so no system tick comes with the tap, and haptic() cannot reach the switch tick from script. If an iPhone tick on a settings toggle matters more to you than the custom look, a plain <input type="checkbox" switch> ticks on its own and renders as a checkbox in browsers that do not know the attribute.
A button
The Haptic Button plays its hapticKind (default light) on touch and pen presses only, so a mouse click never asks for a vibration. In mode="hold" it plays light when the hold starts, success once onConfirm has finished, warning for a tap that was too short and error when onConfirm rejects; with onLongPress set, a long press plays medium.
"use client";
import { Send } from "lucide-react";
import { HapticButton } from "@/components/ui/haptic-button";
export function SendRent({ onSend }: { onSend: () => Promise<void> }) {
return (
<HapticButton mode="hold" fullWidth leftIcon={<Send />} labels={{ holdHint: "Hold to send" }} onConfirm={onSend}>
Hold to send $1,850
</HapticButton>
);
}
> Live demo: Haptic Button. The original article embeds an interactive demo here (Hold the Hold to send button with a finger on an Android phone: a light tick starts the fill, a success pattern lands with the check, and a quick tap shakes the button with a warning tick). Try it live on wingo-ui.com or open the Haptic Button docs. Install: npx wingo-ui@latest add haptic-button
For an ordinary Button that should tick on both platforms, combine the call and the overlay. The Button is already position: relative, so the overlay covers it:
"use client";
import { Button } from "@/components/ui/button";
import { IOSTapTick } from "@/components/ios-tap-tick";
import { haptic } from "@/lib/haptics";
export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
return (
<Button
onClick={async () => {
haptic("light"); // android; the overlay ticks on iPhone
try {
await onSave();
haptic("success"); // android only: iPhone has no tick after an await
} catch {
haptic("error");
}
}}
>
Save
<IOSTapTick />
</Button>
);
}
A wheel
The Picker Wheel plays a selection tick each time a new row reaches the center while a finger, a mouse drag or a scroll wheel drives it, and never for keys, clicks or value changes from outside. haptics={false} turns it off.
"use client";
import { PickerWheel, pickerRange } from "@/components/ui/picker-wheel";
const hours = pickerRange(8, 19, { pad: 2 });
const minutes = pickerRange(0, 55, { step: 5, pad: 2 });
export function AppointmentTime() {
return (
<PickerWheel
aria-label="Appointment time"
loop
defaultValue={["10", "30"]}
columns={[
{ id: "hour", label: "h", options: hours },
{ id: "minute", label: "min", options: minutes },
]}
/>
);
}
> Live demo: Picker Wheel. The original article embeds an interactive demo here (Flick a column on an Android phone: each row that reaches the center ticks once, and the value changes when the wheel comes to rest). Try it live on wingo-ui.com or open the Picker Wheel docs. Install: npx wingo-ui@latest add picker-wheel
This is the clearest case of the platform gap. On Android every row that passes the center ticks. On an iPhone the wheel is silent from iOS 26.5, because a spin is a scroll and never produces the trusted click the switch needs; the band, the tilt and the snap carry the feedback there.
How do you let people turn haptics off?
Give them a setting and pass it to the helper once. Every haptic feedback web app needs one: some people find vibration distracting, and the browser gives a page no way to read the phone's own haptics setting.
"use client";
import { useEffect, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { configureHaptics, haptic } from "@/lib/haptics";
export function VibrationSetting() {
const [on, setOn] = useState(true);
useEffect(() => {
configureHaptics({ enabled: on });
}, [on]);
return (
<Switch
label="Vibrations"
description="A short tick when you tap, hold or pick"
labelPosition="start"
checked={on}
onCheckedChange={(next) => {
setOn(next);
// the effect has not run yet, so force the confirmation tick
if (next) haptic("light", { force: true });
}}
/>
);
}
Store the value with the rest of the user's settings. Every component that ticks through the helper follows it without a prop: the Haptic Button, the Picker Wheel, and the Pull To Refresh from our pull to refresh guide among them.
What comes after navigator.vibrate?
A semantic API with named effects, if browsers adopt the one being drafted now. The Web Haptics API started as a proposal to the CSS Working Group in March 2026 and has been incubated at the WICG since April. Its draft has a navigator.playHaptics(effect, intensity) call and a nested @haptic CSS rule, with six named effects (hint, edge, tick, align, success, error) and an intensity from 0 to 1 that the browser maps to the platform's own haptics. The call returns undefined, so a page learns nothing about the device. As of October 2026 Chrome Platform Status lists Web haptics as Proposed, and no stable browser ships it.
The shape is familiar: named kinds instead of millisecond patterns. If your app already funnels every tick through one haptic(kind) call, moving to a new API later means changing one file.
Where should you start?
Add a helper first, then tick the three places users touch most: toggles, the primary button and any wheel or slider. The dependency-free lib/vibrate.ts above covers Android. npx wingo-ui@latest add haptics copies Wingo UI's lib/haptics.ts instead; it is part of Wingo UI Pro, like the Haptic Button and the Picker Wheel, while the Switch is free. For the rest of the phone checklist (bottom sheets, 44px touch targets, safe area insets), read mobile-first React components or browse the Mobile UI in React category.
Components in this post
- Haptics (Pro): Haptic feedback for the web in one call: short vibration ticks on Android and the system tick on iPhone, silent everywhere else. Install: npx wingo-ui@latest add haptics
- Haptic Button (Pro): A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode. Install: npx wingo-ui@latest add haptic-button
- Switch (Free): An on / off setting that applies at once: a spring thumb that stretches and drags like iOS, thumb icons, and async saves that roll back. Install: npx wingo-ui@latest add switch
- Picker Wheel (Pro): The iOS wheel picker for the web: columns that spin with native momentum and click into place on a 3D cylinder, inline or as a field. Install: npx wingo-ui@latest add picker-wheel
FAQ
Does navigator.vibrate work on iPhone?
No. Safari has never implemented the Vibration API, and as of October 2026 caniuse lists no version of Safari on iOS that supports it. The only haptic a web page can produce on an iPhone is the system tick of a native switch input, and since iOS 26.5 only the user's own tap on it plays that tick.
Why does navigator.vibrate return true on my laptop?
Because true only means the browser accepted the pattern. Desktop Chrome exposes navigator.vibrate and, once the page has seen a click, returns true for a valid pattern even though there is no motor to run. Use the return value to detect refusals and never to detect hardware.
Why does navigator.vibrate do nothing on my Android phone?
The usual causes are a page the user has not tapped yet (Chrome blocks the call and logs a warning until the page has sticky user activation), a hidden tab, silent or Do Not Disturb mode, or Firefox, which removed the API in version 129.
Can a web page control vibration intensity?
Not with the Vibration API, which only switches the motor on and off for the durations you pass. The Web Haptics API proposal, incubated at the WICG since April 2026, adds named effects with an intensity from 0 to 1, but as of October 2026 no stable browser ships it.
Is the Wingo UI haptics helper free?
No. The haptics helper, the Haptic Button and the Picker Wheel are Pro items; the Switch is free. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add haptics copies lib/haptics.ts into your project.
---
This article was originally published on the Wingo UI blog: Web Vibration API: Haptic Feedback on Android and iPhone. The original has the live, interactive demos and is kept up to date.
Mentions
Web 標準動向 2026年10月版 こんにちは! サイボウズ株式会社 プロダクトエンジニアの くらっち (@kuracchi04) です。 はじめに サイボウズは 2025 年 4 月より、W3C のメンバーに加入しました。 blog.cybozu.io 標準化プロセスに関わることができるようになるための最初の一歩として、フロントエンドエンジニアの一部のメンバーは積極的に Web 標準のキャッチアップを行っています。 そこで、毎月メンバーが興味を持った Web 標準に関する話題や、実際に標準化プロセスに関わることができた場合にはその報告などを 1 つの記事としてまとめ、紹介していきます。 また、ここでは W3C に限らず、TC39 や WHATWG などの標準化団体のトピックについても扱います。 ! 今月の執筆者は以下の 4 名です。 nus3 State of系サーベイへのスポンサーのお知らせを執筆 Saji ECMA402 (Intl) 周りのトピックを執筆 saku HTML, CSS に関連するトピックを執筆 mehm8128 主にアクセシビリティに関連するトピックを執筆 くらっち ECMA262 周りのトピックを執筆 State of系サーベイへのスポンサー State of JS などを運営されている Sacha Greif さんのスポンサーを、弊社でさせていただくことになりました! devographics.com State of 系のサーベイでは、開発者が実際にどの機能やライブラリを利用していて、それらをどう感じているのかを知ることができます。サイボウズでも、プロダクト開発の際に参考にさせてもらっています。 State of 系のサイトにメールアドレスを登録しておくと、サーベイの開始時期をメールで知らせてくれます。結果を見るだけでなく、普段プロダクト開発をしている方はぜひサーベイに参加してみてください。 回答していく中で、この 1 年で追加された機能を振り返ることができたり、知らなかった機能に出会えたりするのでおすすめです。 HTML Updated Candidate Recommendation: Scalable Vector Graphics (SVG) 2 w3.org SVG2 の Candidate Recommendation Snapshot が更新されました。 2026/12/1 までコメントを募集しているとのことです。 Blink: Intent to Prototype: The focusreset event groups.google.com 要素が消えてキーボードフォーカスがリセットされたときに発火する、focusreset イベントの Intent to Prototype が出ました。 Chromium では要素が削除されたときに blur イベントが発火しますが、Gecko と WebKit では発火しないため、新たにイベントを用意し、Chromium でも要素が削除されたときにはこちらのイベントを発火するようになる予定とのことです。 これは、UI ライブラリで hacky な方法を使ってフォーカス制御している utils などを置き換えるのに役に立ちます。 Blink: Intent to Prototype: document.advanceFocus() and beforefocus groups.google.com element.focus() はキーボードフォーカスの移動先が分かっている場合に移動先要素のメソッドを利用して発火します。しかし、Tab キーを押したときのように、ブラウザが次の移動先を選択してキーボードフォーカスを移動させたいときのメソッドがありませんでした。 そこで現在 Open UI で議論中である document.advanceFocus() や beforefocus を用いて、移動先要素の計算をブラウザに委ねて「次のフォーカス可能要素にキーボードフォーカスを進める」ということを命令するメソッドが開発されるようです。 focusgroup 属性よりも低レベルで命令的なメソッドになっています。 併せて、要素がフォーカス可能かどうかを紹介する API の案も出されています。 Proposal: Expose sequential focus navigation primitives · Issue #13051 · whatwg/html Gecko: Intent to prototype: Retain focus when elements become disabled groups.google.com button などの要素にキーボードフォーカスしている最中にその要素が disabled になったとき、キーボードフォーカスを失わないようにする変更のプロトタイプが作成されています。 今まではキーボードフォーカスを失っていたため、disabled 属性の代わりに aria-disabled などを用いて無効化状態を実装する hack が用いられることがありましたが、そのような対応が不要になります。 なお、一度フォーカスを外して再度戻ったときには、今まで通りフォーカスが当たらないようです。 Should focused elements which become disabled lose focus? · Issue #13159 · whatwg/html Ship: HTML-in-canvas groups.google.com Chrome の 157 から Stable で HTML-in-Canvas が Ship されます。Stable での Ship はブラウザの中では初めてとなります。 これまで Canvas では、テキスト選択やページ内検索、翻訳、Autofill、アクセシビリティが失われ、逆に HTML を重ねると 3D 空間での前後関係やフレーム同期ズレ、毎フレームの CSS 再計算が課題となっていました。 <canvas content="drawable"> 内の要素を drawElementImage() で描画すれば、ブラウザ標準機能や AI エージェントと親和性の高いネイティブ HTML の機能を保ったままシェーダー表現を利用可能です。 Three.js や Babylon.js、PlayCanvas、PixiJS でも対応が進んでいます。 HTML-in-Canvas in Chrome 157: Interactive DOM inside 2D, WebGL, and WebGPU Overscroll areas – 28 October 2026 w3.org サイドメニューやボトムシートを、スクロールの仕組みを使って宣言的に実装する Overscroll Areas の設計が議論されています。2025 年の TPAC では HTML と CSS のどちらで扱うかが論点でしたが、現在は HTML の属性と Invoker Commands で構造と操作を表し、CSS で表示方法を制御する方向で進んでいます。scroll chaining、scroll snap、inert、backdrop、Invoker Commands など、既存の仕組みを組み合わせる設計です。 また、ボタンを必須とすることで、スワイプ操作を使えないキーボードや支援技術の利用者にも操作手段を提供可能にする点も決まっています。ボタンのデザインを前提としたスワイプデザインは、デザイナーにもコミュニケーションが必要になる部分かもしれません。 Overscroll Areas - TPAC 2026 whatwg/spec-ai: AI tooling for working with WHATWG standards whatwg/spec-ai WHATWG が、仕様の執筆やレビューを AI agent で支援するための repository を公開・運用しています。 HTML Living Standard の Pull Request をレビューし、問題の修正まで行う review-and-fix Skill や、WHATWG の記法を確認する静的チェックが追加されています。Pull Request 増加へのレビューを支援する Skill という位置付けです。 Custom Attributes webplatformco/project-custom-attributes 既存の HTML、SVG、MathML 要素へ、再利用可能な振る舞いを属性として追加する Custom Attributes が提案されています。要素自体を Custom Element に置き換えるのではなく、カスタムの属性で振る舞いのみを付与する発想です。 Customized built-in elements は、<button is="..."> のように is 属性を使って既存要素を拡張します。Interop 2027 では、相互運用が進んでいない customized built-in elements(is 属性)を非推奨化する提案も出ています。一方、Custom Attributes は要素の種類を保ったまま、複数の独立した振る舞いを属性として組み合わせられることを目指しています。 TPAC での議論も前向きに進んでおり、今後注目していきたい機能の一つです。 CSS [css-logical] Flow-relative syntax for margin-like shorthands csswg-drafts#1282 長年要望の多かった、Logical Properties のショートハンド対応方法が決定されました。 CSS には margin-block-start や margin-inline-end といった logical longhand がありますが、margin: 10px 20px 15px のような shorthand の値は、物理的な top、right、bottom、left の順序で展開されます。shorthand は parse 時に longhand へ展開されるのですが、logical property を物理方向へ対応付けるには、後から確定する writing-mode や direction が必要です。この処理順の違いが、logical shorthand の導入を難しくしてきました。 解決策として、パース時に Logical Properties であることをフラグとしてマークする方法が議論されました。今回の resolution では、値の末尾へ !logical または !physical を付ける形式と、複数の宣言を @logical または @physical で囲む形式を仕様化する予定です。 .box { margin: 10px 20px 15px !logical; } @logical { .box { margin: 10px 20px; padding: 5px; } } CSSWG TPAC misc w3.org w3.org [css-fonts][mediaqueries-5] Add prefers-bold-text user preference media feature csswg-drafts#14477 2026年9月の記事で紹介した prefers-bold-text media query を Media Queries に追加することが決まりました。 [css-mixins] Types for private properties (in mixins and elsewhere) and probably function locals csswg-drafts#14442 @private と @function の本文内に @property をネストし、local な custom property の型と初期値を定義できるようにすることが決まりました。 @mixin --card { @private { @property --space <length>: 1rem; } padding: var(--space); } Proposal: Custom Highlight API Extensions csswg-drafts#14613 Custom Highlight API で opacity と CSS Animations、CSS Transitions を利用できるようにすることが決まりました。多数の span 要素を生成せずに、Range で指定した文字列の背景色や文字色、text decoration を fade させられるようになります。 [css-grid-3][css-display-4] Naming for grid lanes reading-flow values csswg-drafts#14290 Grid Lanes の視覚的な配置に合わせて読み上げや focus navigation の順序を制御する、reading-flow の値が決まりました。 grid-lanes-track は track に沿った順序、grid-lanes-flow は item を配置する flow に沿った順序を表します。order property を反映する grid-lanes-order も追加されます。 .gallery { display: grid-lanes; reading-flow: grid-lanes-flow; } Blink: Intent to Prototype: CSS Linked Parameters for External SVGs groups.google.com 外部 SVG へ名前付きの CSS 値を渡し、SVG 側で env() を使って読み取れるようにする CSS Linked Parameters の Intent to Prototype です。 外部 SVG は参照元 document の style や custom property を継承しません。そのため、theme に応じて icon の色を変えるには、SVG を inline 化するか、色ごとに別の file を用意する必要がありました。この提案では、1 つの cache された SVG を再利用しながら、参照ごとに異なる値を渡せます。 img { link-parameters: param(--icon-color: green); } <circle fill="env(--icon-color, black)" /> WebKit Features for Safari 27.2 webkit.org Safari 27.2 では、Customizable Select と Grid Lanes の既存実装が追加されました。両実装とも Chrome に続く主要ブラウザ 2 つ目の実装です。 ARIA・WCAG feat: add listview role to ARIA aria#2951 TPAC にて、List View というインタラクティブなリスト構造のセマンティクスを定義する議論がありました。 listbox とは違ってフォーム要素ではなく、grid とは違って 1 次元であるような UI で、チャットのメッセージ一覧、最近開いたファイル一覧、ページネーション、カルーセルなどが例として挙げられています。 List View 内では Tab キーの代わりに矢印キーで操作できるようにすることで、ページを読み進めるのに Tab キーを押す手間を省くことができるというメリットが提示されています。 これに対して、新しく listview のような親 role と、それに対応する子 role を追加することが決定しました。そして、ul, ol 要素に listview role がつけられると、その子の li 要素には自動で子 role がつくようにするとのことです。 なお、role 名は未確定のようです。 参考 Interactive lists | Sarah Higley A proposal for a listview role to discuss during TPAC 2026 Filtering a listbox with a text field (filterable select) aria#2841 現在 Open UI で検討されている Filterable Select という UI のアクセシビリティマッピングについて、TPAC で議論がありました。 テキスト入力欄に隣接してリストボックスが存在している、GitHub のラベルフィルター UI のようなものが想定されています。 一見 combobox role が適切なように思えますが、今回のパターンは開閉状態を持たない、入力したテキストとは別で選択肢の値が送信値になる、入力しなくても直接選択肢を選択できるといった違いから、適さないという結論になりました。 また、aria-activedescendant を誤った使い方をしてアクセシビリティ上の問題が発生するケースも取り上げられ、aria-activedescendant の仕様が改善されることになりました。 [accname PR 235] feat: update step 2F to exclude nodes referenced by aria-actions aria#2208 accessible name に使われる text alternative の計算ステップにおいて、aria-actions で参照されているノードが除外されるようになるようです。 Simplify allowances for explicit declaration of implicit role on elements html-aria#612 button 要素の button is also allowed, but NOT RECOMMENDED. や del 要素の Any role, though deletion is NOT RECOMMENDED. など、要素が持つ暗黙の role を指定するのを非推奨であると記載するのをやめるという issue です。 暗黙の role を指定するのは多くの場合不要ですが、指定することでアクセシビリティを損なうこともありません。よって、基本的にはこのような記述を削除し、暗黙の role を指定することでアクセシビリティ上の問題が発生する場合のみ明記する方針になるとのことです。 browser accessibility heuristics wiki aria#2931 以前紹介した browser accessibility heuristics wiki のページに、WebKit で ul / ol に対して list-style-type を none に指定すると、list role がつかなくなるというヒューリスティックが記載されるようです。これは不具合ではなく意図的な動作です。 参考 x.com 170179 – VoiceOver does not announce a list for groups of links when list-style: none; ACD - Accessibility Compat Data acd.lolaslab.co ACD Project の Web サイトが公開されました。 ACD の概要や貢献方法、その他記事が公開されています。 JavaScript ECMA402 今月公開された、10 月 8 日の TG2 meeting notes の内容です。 github.com/tc39/ecma402/blob/main/meetings/notes-… How should JS handle un-deprecation of EST5EDT, CST6CDT, MST7MDT, PST8PDT in TZDB 2026d? ecma402#1103 EST5EDT などのタイムゾーン ID は、IANA の TZDB で一度非推奨になっていました。しかし互換性への苦情を受けて TZDB 2026d で非推奨が取り消され、再び独立したゾーン(primary zone)として扱われるようになりました。このままだと、これらの ID が Intl.supportedValuesOf("timeZone") の結果に含まれ、タイムゾーンの選択 UI にも表示されることになります。 これらのタイムゾーンは、過去や将来の日付で都市ベースのタイムゾーンと異なる時刻になることがあり、事情を知らないユーザーが選択 UI でこれらを選んでしまうことが懸念されていました。またこれらのタイムゾーンに対する現在のブラウザの挙動は 2 通りに分かれてしまっているという問題もあります。Firefox と Chrome はこれらを都市ベースの ID(America/New_York など)の alias として扱い、Safari は受け付けません。 会議の結果、これらを canonical ID(Intl.supportedValuesOf で列挙される正式な ID)として認めないことを仕様に明記し、alias として受け付けるかどうかは実装に任せることになりました。こうすると Firefox / Chrome / Safari の現在の挙動はどれも仕様に沿ったものになります。この方針で仕様の PR を作り、TG1(TC39 の本会議)に提出して承認を求める予定です。 add String.toTitleCase, String.toLocaleTitleCase ecma402#294 文字列をタイトルケース(単語の先頭を大文字にする形)に変換するメソッド toTitleCase / toLocaleTitleCase を追加しようという Issue です。CSS には text-transform: capitalize がありますが、JavaScript には同等の機能がありません。またアイルランド語のように通常より複雑な変換が必要な言語があり、CLDR や ICU がそれに対応していないという課題もあります。 会議の結果、CSSWG と今後の進め方を相談するため、TPAC のブレイクアウトセッションに申し込むことになりました。 Duration / time units in Amount proposal-amount#139 Amount は、数値と単位を組にして扱う ECMA-262 の提案です。この Issue では、minute-and-second のような時間の単位を持つ Amount をどうフォーマットするかが議論されました。Amount のフォーマットには Intl.NumberFormat を使いますが、複数の単位の組み合わせを表示する Intl Sequence Units が時間の単位を対象にしていないため、そのままではフォーマットできません。 Intl.DurationFormat でフォーマットすればよいという意見が出ましたが、その場合は toLocaleString() の options が NumberFormat 用と DurationFormat 用の 2 通りの形になってしまうという問題があります。そのため、時間の単位ではエラーを投げてもよいという意見もありました。 会議では結論が出ず、Amount を Temporal.Duration に変換する手段(Amount.prototype.toDuration を追加する、Temporal.Duration.from が Amount を受け取れるようにする、など)を別の Issue で検討することになりました。 Exact input handling for sequence unit values: Iterable vs. Array-like vs. IsArray proposal-intl-sequence-units#19 Intl Sequence Units で、フォーマットする値をどの形式(Iterable / Array-like / 配列のみ)で受け取るかについての Issue です。しかし、この Issue 自体が TC39 の AI ポリシーに違反しているという指摘があり、会議では内容を議論せずに延期することになりました。 Should we coerce the value field? proposal-intl-unit-protocol#6 Intl Unit Protocol は、value と unit のフィールドを持つオブジェクトを Intl.NumberFormat.prototype.format() に渡して、数値と単位をまとめてフォーマットできるようにする Stage 2 の提案です。この Issue では、value フィールドにも既存の format() と同じ型変換を行うか、つまり Symbol.toPrimitive を実装した独自の数値型も受け付けるかが議論されました。 会議の結果、Intl.NumberFormat が以前から独自の数値型を受け付けていることにそろえ、型変換を行う現在の仕様を維持する方向になりました。 Should we check for the protocol even if the formatter is not a unit/currency formatter? proposal-intl-unit-protocol#7 現在の仕様では、format() は style に関係なく、引数から常に Intl Unit Protocol のフィールドを読み取ります。そのため、単位を使わない NumberFormat(style: "decimal" など)にプロトコルのオブジェクトを渡したときにどう扱うかが問題になっていました。 会議の結果、style が "unit" か "currency" のときだけフィールドを読み取るように変更することになりました。仕様の挙動が変わる normative な変更なので、次回の TG1 に提出して承認を求める予定です。 Input unit type checking proposal-intl-unit-protocol#8 unit フィールドに不正な単位が入っていたときに、どの時点で例外を投げるかについての Issue です。フィールドの読み取りなど、外から観測できる処理との順序がまだ決まっていません。 会議の結果、この点は Stage 2.7 に向けたレビューで確認することになりました。 Cyclic year doesn't show up in resolvedOptions ecma402#816 中国暦のように干支で年を表すカレンダーで、Intl.DateTimeFormat.prototype.resolvedOptions() の結果に year が含まれないという Issue です。以前の会議で修正方針に合意していましたが、仕様と実装のどちらを直すべきかが改めて確認されました。 仕様では、表示パターンに relatedYear が含まれていれば resolvedOptions() の結果にも year が含まれると定めています。会議の結果、仕様は正しく実装が準拠していないことが確認され、この挙動を検証する test262 のテストを追加することになりました。 Normative: Define %TypedArray%.prototype.toLocaleString ecma402#1095 TG2 の議題ではありませんが、今月マージされた normative な PR です。ECMA-262 は、ECMA-402 も実装する処理系に対して、%TypedArray%.prototype.toLocaleString(Uint8Array などの型付き配列の toLocaleString)を Array.prototype.toLocaleString と同じように ECMA-402 の定義で置き換えるよう求めています。しかし、ECMA-402 にはその定義がありませんでした。 この PR で ECMA-402 に定義が追加され、各要素の toLocaleString に locales と options が渡されることが明記されました。 ECMA262 Deferred re-exports advanced to stage 2.7 tc39/proposal-deferred-reexports 再エクスポートに defer を付けることで、使われていないモジュールの読み込みと実行をスキップできるようにする提案です。 TC39 第 116 回の MTG で Stage 2 から Stage 2.7 へ昇格しました。 ただし、エディタ(ECMA262の仕様書の編集を行うグループ)のレビューが未完了のため、エディタ承認を条件とした昇格です。 ライブラリのエントリポイントがすべての公開 API を再エクスポートする「バレルモジュール」パターンでは、使わない機能まで含めてモジュールグラフ全体が読み込まれます。 バンドラの tree-shaking は副作用の扱いがツールごとに異なり、ブラウザで ESM を直接実行する場合は使えません。 この提案は、「使われなければ無視して良い」再エクスポートを表現できるようにするものです。 // math.js export defer { add } from "./math/add.js"; export defer { sub } from "./math/sub.js"; export { mul } from "./math/mul.js"; // main.js import { add, mul } as math from "./math.js"; import("./math.js", { exports: ["add", "mul"] }); // 動的 import 版 Composites advanced to Stage 2 tc39/proposal-composites Map や Set のキーとして使える、値ベースの等価性を持つ Composite を導入する提案です。 TC39 第 116 回の MTG で Stage 1 から Stage 2 へ昇格しました。 現在の Map / Set は要素の同一性判定に SameValueZero を使うため、オブジェクトは「自分自身とだけ等しい」扱いになります。 構造化された値をキーにしたくても、中身が同じ 2 つのオブジェクトは別物と判定されてしまい、この挙動を変える手段はありません。 回避策として JSON.stringify で文字列化してキーにする方法がありますが、キーの列挙順で結果が変わる、循環参照で例外になるなど、正しく使うのが難しいものでした。 Composite は「中身で等価判定したい場合に明示的に選べる別の手段」で、これを使うと同じ中身なら同じ参照になるオブジェクトを作れるようになります。 const pos1 = Composite({ x: 1, y: 4 }); const pos2 = Composite({ x: 1, y: 4 }); pos1 === pos2; // true(同じ値の組からは同じオブジェクトが返る) // 既存の Map / Set でそのままキーとして機能する const itemAtPosition = new Map(); itemAtPosition.set(pos1, "book"); itemAtPosition.get(Composite({ x: 1, y: 4 })); // "book" // キーはソートされ正規形を持つため、記述順は等価性に影響しない Composite({ b: 2, a: 1 }) === Composite({ a: 1, b: 2 }); // true Bigint from exponential advanced to stage 2 tc39/proposal-bigint-from-exponential 1e6n のような指数表記で BigInt を書けるようにし、BigInt("1e6") のような文字列からの変換も受け付けるようにする提案です。 TC39 第 116 回の MTG で Stage 1 から Stage 2 へ昇格しました。 // これまで: 桁を手で書く const scale = 1_000_000_000_000_000_000n; BigInt(1e21); // Number を経由するので精度が保証されない // この提案: 指数表記で書ける const scale2 = 1e21n; BigInt("1e21"); // 1000000000000000000000n BigInt("86.4e12"); // 86400000000000n(整数になる小数部は許容) Baseline 📃 October 2026 release notes web-platform-dx.github.io Misc THIS IS FOR EVERYONE の日本語訳が発売 str.toyokeizai.net ティム・バーナーズ=リー氏の THIS IS FOR EVERYONE の日本語訳本が、10/17 に発売開始されました。 Web Sustainability | Our mission | W3C w3.org W3C のミッションの 1 つとして、サステナビリティのページが追加されました。 W3C のサステナビリティへの取り組みの概要や、Sustainable Web Interest Group で作業中の Web Sustainability Guidelines の紹介があります。 Web Sustainability Guidelines については、以下の記事で紹介されています。 Web Sustainability Guidelinesとは Blink: Intent to Prototype: Decisions API groups.google.com 自然言語の入力を、構造化された質問と選択肢に照らして端末上で評価する Decisions API の Intent to Prototype です。Intents にも書かれている通り、ブラウザ上の System One モデル (e.g. Jev) のようなものです。 現時点では design sketch の段階で、仕様や ship の計画はありません。prototype を通して、FB を募り、今後の方針を決めていく段階でしょう。 Decisions API Explainer
csswg-drafts#7561 appears to be a place to start digging
Web Vibration API: Haptic Feedback on Android and iPhone TL;DR As of October 2026 the Web Vibration API (navigator.vibrate) works only in Chromium browsers on Android, only after the user has tapped the page, and with no control over intensity; Safari on iPhone has never shipped it. The one haptic a page can trigger on an iPhone is the system tick of a native HTML switch, and since iOS 26.5 WebKit plays it only when the user's own tap toggles the switch, never for a click from JavaScript. Put navigator.vibrate behind one helper that returns false instead of throwing, lay a label for a hidden switch over buttons that should tick on iPhone, and never let a tick be the only feedback. You want a tap to tick under the thumb the way it does in a native app, so you reach for the web vibration API. On an Android phone navigator.vibrate(10) works on the first try. On an iPhone nothing happens, on a laptop the call returns true and nothing happens, and the iPhone workaround you found on GitHub stopped working in iOS 26.5. This tutorial maps what a page can really trigger in October 2026, builds a helper that stays silent wherever it cannot play, and wires haptics into a switch, a button and a wheel picker in React. What can the Web Vibration API do in 2026? It can switch the vibration motor on and off for durations you give in milliseconds, in Chromium browsers on Android, after the user has tapped the page. That is the whole API: one method, navigator.vibrate, that takes a number or an array of alternating vibrate and pause times. There is no intensity, no waveform and no way to ask whether the device has a motor at all. Software Mansion's August 2026 overview of web haptics sums up the support in one line: "the vibration API is effectively limited to Chromium-based browsers." Here is where a tick can reach a finger, as of October 2026 (caniuse for support, our own test in desktop Chromium for the last row): | Where the page runs | navigator.vibrate | What the user feels | | --- | --- | --- | | Chrome or Samsung Internet on Android | supported | the pattern you pass, once the page has seen a tap | | Firefox | removed in version 129, not supported on Android | nothing | | Safari on iPhone, and home screen web apps | never shipped | the system tick of a native switch, only when the user's own tap toggles it | | Chrome on a desktop | present, returns true | nothing, there is no motor | The last row is the trap. Feature detection with "vibrate" in navigator says yes in desktop Chrome, so it tells you nothing about hardware. The boolean that navigator.vibrate() returns only says the browser accepted the pattern; it says nothing about whether anyone felt it. How does navigator.vibrate behave on Android? It plays the pattern you pass and replaces any pattern already running, within limits you can read in Chromium's vibration controller: navigator.vibrate(8); // one 8ms pulse: a light tick navigator.vibrate([10, 60, 16]); // pulse, pause, pulse: a success pattern navigator.vibrate(0); // cancels whatever is running (so does []) - It needs a tap first. MDN lists sticky user activation as a requirement. Until the page has seen a tap, a click or a key press, Chrome returns false and logs "Blocked call to navigator.vibrate because user hasn't tapped on the frame or any embedded frame yet". Once granted, the activation lasts until the page navigates, so a tick from a scroll handler or after an await works later on. - It is silent in the background. The call returns false while the page is hidden. - Long patterns are trimmed. Chromium caps each entry at 10,000ms, keeps the first 99 entries and drops a trailing pause. - The phone can overrule you. MDN notes that some devices do not vibrate in silent or Do Not Disturb mode. Keep the pulses short; the helper below uses 4 to 22ms per pulse. The 200ms buzz in many examples (MDN's first one included) reads as a notification, which is the wrong message for a toggle. Does the Web Vibration API work on iOS? No. Safari has never implemented navigator.vibrate, and caniuse lists no version of Safari on iOS with it, up to 27.2. Look up web vibration API iOS support and you land on one workaround: Safari 17.4 added a native switch control, <input type="checkbox" switch> (WebKit's announcement), and toggling it plays the system haptic tick. Until iOS 26.5, libraries such as ios-haptics played that tick on demand by calling .click() on a hidden label wired to the switch. That no longer works. The ios-haptics README of April 2026 says the trick "only works on ios 17.4 to 26.4, as apple patched it in ios 26.5". The WebKit fix, merged into the main branch on May 21, 2026, closes that gap: WebKit already required user activation for the haptic, and now it also requires a trusted event, which a click forwarded from label.click() no longer is. What still works is a real tap that lands on a label for the switch. The tap produces a trusted click, the label forwards it to the switch, and because the original click was trusted, the WebKit change still lets the switch tick. The ios-haptics source moved to exactly that on September 22, 2026: it lays a transparent label over your element. Here is the same idea as a React 19 component: // components/ios-tap-tick.tsx "use client"; // iOS plays the switch haptic only for a trusted click, so the user's own tap has to land on a label for the switch. // put it inside a positioned button; it covers the button and the click still bubbles to the button's onClick export function IOSTapTick() { return ( <label aria-hidden="true" data-slot="ios-tap-tick" className="absolute inset-0 touch-manipulation [-webkit-tap-highlight-color:transparent]" > <input type="checkbox" // react 19 passes the unknown attribute through; the spread keeps TypeScript quiet {...{ switch: "" }} tabIndex={-1} // the label, not the switch, sits under the finger, so a swipe that starts here still scrolls the page className="invisible absolute m-0 size-px" // the label forwards the click to the switch: stop that second click before it reaches the button onClick={(event) => event.stopPropagation()} /> </label> ); } Use it in buttons, not links: in our Chromium test a label inside an <a> ran the link's onClick but took over the default action, so the link never navigated. Leave the input without a name, so it never ends up in a form submission. Outside iOS a tap on it only toggles a hidden checkbox nobody sees, so it can render on every platform and never causes a hydration mismatch. On an iPhone you get one fixed tick per tap, from the tap itself: no patterns, nothing after an await, nothing during a drag, and nothing for a wheel spinning past rows, because a scroll never produces a click. How do you call navigator.vibrate safely where it is unsupported? Check for the method itself, check activation, catch everything, and return a boolean. Here is the whole helper without dependencies: // lib/vibrate.ts export type HapticKind = "selection" | "light" | "medium" | "heavy" | "success" | "warning" | "error"; const PATTERNS: Record<HapticKind, number | number[]> = { selection: 4, light: 8, medium: 14, heavy: 22, success: [10, 60, 16], warning: [16, 70, 16], error: [22, 50, 22, 50, 22], }; let enabled = true; export function setHapticsEnabled(value: boolean) { enabled = value; } export function vibrate(kind: HapticKind = "light"): boolean { if (!enabled) return false; // node 21+ has a global navigator too, so test for the method, not for navigator if (typeof navigator === "undefined" || typeof navigator.vibrate !== "function") return false; // chrome blocks the call (and logs a warning) until the page has seen a tap if (navigator.userActivation && !navigator.userActivation.hasBeenActive) return false; try { return navigator.vibrate(PATTERNS[kind]); } catch { return false; // feedback is never worth an exception } } The Node check matters. Node 21 and later define a global navigator with no vibrate, so a helper that only checks typeof navigator gets past its guard on the server and calls a method that is not there. Wingo UI's haptics helper is the same idea plus an iPhone fallback and a force option that plays a tick even when haptics are off. haptic(kind) returns whether a haptic was sent, configureHaptics({ enabled, iosFallback }) sets the app-wide options, and supportsHaptics() tells you whether the browser exposes a haptic path at all (call it in an effect: it is false on the server, and true in desktop Chrome for the reason above). > Live demo: Haptics. The original article embeds an interactive demo here (On an Android phone, tap each tile to feel the seven kinds; the bars draw each vibration pattern and the line under the card shows what haptic() returned). Try it live on wingo-ui.com or open the Haptics docs. Install: npx wingo-ui@latest add haptics A caveat about our own code: the helper's iosFallback option, on by default, still clicks a hidden label from script. That plays on iPhones up to iOS 26.4, is ignored from 26.5, and the call still returns true there because WebKit reports nothing back. If you add the tap overlay above, call configureHaptics({ iosFallback: false }) once so iPhones on older versions do not tick twice. The snippets below import Wingo UI's helper. With the dependency-free file above, import vibrate from @/lib/vibrate instead and use setHapticsEnabled in place of configureHaptics; it has no force option. Which haptic should each interaction get? One tick per event, sized to the event. These are the patterns the helper sends on Android: | Kind | Pattern (ms) | Use it for | Wingo UI component that plays it | | --- | --- | --- | --- | | selection | 4 | a row of a wheel or a slider step passing the center | Picker Wheel | | light | 8 | a tap, a toggle | Haptic Button press | | medium | 14 | a long press, a snap, a crossed threshold | Haptic Button long press, Pull To Refresh arming, Slide To Confirm armed | | heavy | 22 | a committed full swipe, a drop | none; call it from your own drop handler | | success | 10, 60, 16 | payment sent, item saved | Haptic Button hold confirmed, Slide To Confirm done | | warning | 16, 70, 16 | a limit reached, a hold released too early | Haptic Button tap in hold mode | | error | 22, 50, 22, 50, 22 | a wrong PIN, a failed save | Haptic Button rejected promise | Three rules keep it from turning into noise. Call it from the handler of a real gesture, never from an effect or a render. Never tick per animation frame; tick when something changes state, like a row reaching the center. And never put information only in the tick: desktop users, iPhone users on a wheel and anyone with a phone on silent will not feel it, so the screen must say the same thing. How do you add React haptic feedback to a switch, a button and a wheel? Call haptic() in the handler where the state changes, once per change. A switch The free Switch calls onCheckedChange on every click, key or drag, and a returned promise makes it spin, then flip back with errorText if the save fails. Tick on the change and again on failure: "use client"; import { Switch } from "@/components/ui/switch"; import { haptic } from "@/lib/haptics"; export function NewMessagesSetting({ save }: { save: (on: boolean) => Promise<void> }) { return ( <Switch label="New messages" description="When someone writes to you about a listing" labelPosition="start" defaultChecked onCheckedChange={async (on) => { haptic("light"); try { await save(on); } catch (error) { haptic("error"); throw error; // the switch flips back and shows errorText } }} /> ); } On iOS 26.5 and later this Switch stays silent: it is a Radix button with role="switch", not the native input, so no system tick comes with the tap, and haptic() cannot reach the switch tick from script. If an iPhone tick on a settings toggle matters more to you than the custom look, a plain <input type="checkbox" switch> ticks on its own and renders as a checkbox in browsers that do not know the attribute. A button The Haptic Button plays its hapticKind (default light) on touch and pen presses only, so a mouse click never asks for a vibration. In mode="hold" it plays light when the hold starts, success once onConfirm has finished, warning for a tap that was too short and error when onConfirm rejects; with onLongPress set, a long press plays medium. "use client"; import { Send } from "lucide-react"; import { HapticButton } from "@/components/ui/haptic-button"; export function SendRent({ onSend }: { onSend: () => Promise<void> }) { return ( <HapticButton mode="hold" fullWidth leftIcon={<Send />} labels={{ holdHint: "Hold to send" }} onConfirm={onSend}> Hold to send $1,850 </HapticButton> ); } > Live demo: Haptic Button. The original article embeds an interactive demo here (Hold the Hold to send button with a finger on an Android phone: a light tick starts the fill, a success pattern lands with the check, and a quick tap shakes the button with a warning tick). Try it live on wingo-ui.com or open the Haptic Button docs. Install: npx wingo-ui@latest add haptic-button For an ordinary Button that should tick on both platforms, combine the call and the overlay. The Button is already position: relative, so the overlay covers it: "use client"; import { Button } from "@/components/ui/button"; import { IOSTapTick } from "@/components/ios-tap-tick"; import { haptic } from "@/lib/haptics"; export function SaveButton({ onSave }: { onSave: () => Promise<void> }) { return ( <Button onClick={async () => { haptic("light"); // android; the overlay ticks on iPhone try { await onSave(); haptic("success"); // android only: iPhone has no tick after an await } catch { haptic("error"); } }} > Save <IOSTapTick /> </Button> ); } A wheel The Picker Wheel plays a selection tick each time a new row reaches the center while a finger, a mouse drag or a scroll wheel drives it, and never for keys, clicks or value changes from outside. haptics={false} turns it off. "use client"; import { PickerWheel, pickerRange } from "@/components/ui/picker-wheel"; const hours = pickerRange(8, 19, { pad: 2 }); const minutes = pickerRange(0, 55, { step: 5, pad: 2 }); export function AppointmentTime() { return ( <PickerWheel aria-label="Appointment time" loop defaultValue={["10", "30"]} columns={[ { id: "hour", label: "h", options: hours }, { id: "minute", label: "min", options: minutes }, ]} /> ); } > Live demo: Picker Wheel. The original article embeds an interactive demo here (Flick a column on an Android phone: each row that reaches the center ticks once, and the value changes when the wheel comes to rest). Try it live on wingo-ui.com or open the Picker Wheel docs. Install: npx wingo-ui@latest add picker-wheel This is the clearest case of the platform gap. On Android every row that passes the center ticks. On an iPhone the wheel is silent from iOS 26.5, because a spin is a scroll and never produces the trusted click the switch needs; the band, the tilt and the snap carry the feedback there. How do you let people turn haptics off? Give them a setting and pass it to the helper once. Every haptic feedback web app needs one: some people find vibration distracting, and the browser gives a page no way to read the phone's own haptics setting. "use client"; import { useEffect, useState } from "react"; import { Switch } from "@/components/ui/switch"; import { configureHaptics, haptic } from "@/lib/haptics"; export function VibrationSetting() { const [on, setOn] = useState(true); useEffect(() => { configureHaptics({ enabled: on }); }, [on]); return ( <Switch label="Vibrations" description="A short tick when you tap, hold or pick" labelPosition="start" checked={on} onCheckedChange={(next) => { setOn(next); // the effect has not run yet, so force the confirmation tick if (next) haptic("light", { force: true }); }} /> ); } Store the value with the rest of the user's settings. Every component that ticks through the helper follows it without a prop: the Haptic Button, the Picker Wheel, and the Pull To Refresh from our pull to refresh guide among them. What comes after navigator.vibrate? A semantic API with named effects, if browsers adopt the one being drafted now. The Web Haptics API started as a proposal to the CSS Working Group in March 2026 and has been incubated at the WICG since April. Its draft has a navigator.playHaptics(effect, intensity) call and a nested @haptic CSS rule, with six named effects (hint, edge, tick, align, success, error) and an intensity from 0 to 1 that the browser maps to the platform's own haptics. The call returns undefined, so a page learns nothing about the device. As of October 2026 Chrome Platform Status lists Web haptics as Proposed, and no stable browser ships it. The shape is familiar: named kinds instead of millisecond patterns. If your app already funnels every tick through one haptic(kind) call, moving to a new API later means changing one file. Where should you start? Add a helper first, then tick the three places users touch most: toggles, the primary button and any wheel or slider. The dependency-free lib/vibrate.ts above covers Android. npx wingo-ui@latest add haptics copies Wingo UI's lib/haptics.ts instead; it is part of Wingo UI Pro, like the Haptic Button and the Picker Wheel, while the Switch is free. For the rest of the phone checklist (bottom sheets, 44px touch targets, safe area insets), read mobile-first React components or browse the Mobile UI in React category. Components in this post - Haptics (Pro): Haptic feedback for the web in one call: short vibration ticks on Android and the system tick on iPhone, silent everywhere else. Install: npx wingo-ui@latest add haptics - Haptic Button (Pro): A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode. Install: npx wingo-ui@latest add haptic-button - Switch (Free): An on / off setting that applies at once: a spring thumb that stretches and drags like iOS, thumb icons, and async saves that roll back. Install: npx wingo-ui@latest add switch - Picker Wheel (Pro): The iOS wheel picker for the web: columns that spin with native momentum and click into place on a 3D cylinder, inline or as a field. Install: npx wingo-ui@latest add picker-wheel FAQ Does navigator.vibrate work on iPhone? No. Safari has never implemented the Vibration API, and as of October 2026 caniuse lists no version of Safari on iOS that supports it. The only haptic a web page can produce on an iPhone is the system tick of a native switch input, and since iOS 26.5 only the user's own tap on it plays that tick. Why does navigator.vibrate return true on my laptop? Because true only means the browser accepted the pattern. Desktop Chrome exposes navigator.vibrate and, once the page has seen a click, returns true for a valid pattern even though there is no motor to run. Use the return value to detect refusals and never to detect hardware. Why does navigator.vibrate do nothing on my Android phone? The usual causes are a page the user has not tapped yet (Chrome blocks the call and logs a warning until the page has sticky user activation), a hidden tab, silent or Do Not Disturb mode, or Firefox, which removed the API in version 129. Can a web page control vibration intensity? Not with the Vibration API, which only switches the motor on and off for the durations you pass. The Web Haptics API proposal, incubated at the WICG since April 2026, adds named effects with an intensity from 0 to 1, but as of October 2026 no stable browser ships it. Is the Wingo UI haptics helper free? No. The haptics helper, the Haptic Button and the Picker Wheel are Pro items; the Switch is free. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add haptics copies lib/haptics.ts into your project. --- This article was originally published on the Wingo UI blog: Web Vibration API: Haptic Feedback on Android and iPhone. The original has the live, interactive demos and is kept up to date.
EPUB航海日誌 — 3.4の海図に合わせるだけの、短い航海のはずだった EPUB 3.4 が来年出るらしい。 電子書籍の形式である EPUB の、次の版である。 W3C の作業部会の予定表には、こう書いてある。 2027 February: EPUB 3.4 final version こちらは AozoraEpub3-JDK21 という、青空文庫のテキストや Web 小説を電子書籍にする道具を保守している。 今の EPUB 3.3 に合っているか見直し、次の 3.4 の草案も読んでおこう。 古いところを直せば終わりである。 サクッと済ませよう。 出港前に、予定の航路を書いておいた。 予定の航路 1. 仕様を読む 2. 古い要素を消す 3. 検証器が通ることを確かめる 4. 出す 4 行である。 短い航海の予定だった。 以前、RAW ファイルから表示用の画像を取り出す話を書いた。あのときは、カメラが作った実物に何度も予想を外された。 今回は国際標準がある。しかも原文まで読める。 今度こそ、まっすぐ進めるはずである。 本稿は、その航海の日誌である。 EPUB を知らなくても読めるように書く。途中で仕様の話が出るが、読めなくても船は進む。 先に結論の一部だけ言っておくと、予定の 4 行のうち、2 行目と 3 行目は出港した日に消えた。 出港前: 積荷を見ておく ■ 出港前 天候: 晴れ 位置: EPUB 3.4 が来年出ると知った 積荷: 縦書きの小説 予定: 短い航海 航海に出る前に、積荷を見ておく。 今回の積荷は、縦書きの小説である。 例 1: 完成品 AozoraEpub3-JDK21 で、宮沢賢治の『銀河鉄道の夜』を電子書籍にした。 青空文庫で公開されている作品である。 Apple Books で開くと、こうなる。 Apple Books(macOS)。縦書き、見出し、ルビ 縦書きである。 「言」の横に小さく「い」、「黒板」の横に「こくばん」が付いている。 ルビ(ふりがな)である。 紙の文庫本で見慣れた組み方が、画面に出ている。 文字を大きくしたり、読む端末を変えたりしても、本として読める。 これが EPUB の面白いところである。 例 2: 船の中身 では EPUB とは何か。 正体は、決まったルールで中身を詰めた ZIP ファイルである。 多くの環境では、拡張子を .zip に変えれば中身を開いて見られる。 『銀河鉄道の夜』の中身は、こうなっていた。 mimetype META-INF/container.xml OPS/package.opf ← 積荷目録 OPS/xhtml/nav.xhtml ← 目次 OPS/xhtml/title.xhtml ← 表題ページ OPS/xhtml/0001.xhtml ← 本文 OPS/xhtml/0002.xhtml OPS/css/vertical.css ← 縦書きの見た目 OPS/css/vertical_font.css OPS/css/vertical_text.css OPS/css/vertical_middle.css OPS/css/vertical_image.css .xhtml はウェブページである。ただし、正確には XHTML という、XML のルールに従って書く HTML である。 XML。 SOAP……うっ、頭痛が……。 昔の仕事で苦労した記憶が少しよみがえった。話を戻そう。 .css はウェブページの見た目の指定である。 つまり、 EPUB は、ざっくり言えばウェブページを ZIP に詰めた本である。 もちろん ZIP の中での置き方にも決まりはある。 本文はウェブページで書く。 見た目は CSS で決める。 何が入っているかは、package.opf という積荷目録に書く。 ほぼ、それだけである。 例 3: 変換の矢印 AozoraEpub3-JDK21 がやっていることは、この矢印である。 青空文庫のテキストには、ルビや見出しが記号で書いてある。 [#5字下げ]一 午後の授業[#「一 午後の授業」は中見出し] 「ではみなさんは、そういうふうに川だと言《い》われたり、乳《ちち》の流《なが》れたあとだと…… 言《い》 が「言」にルビ「い」である。 これを、EPUB の中ではウェブページの書き方にする。 <div class="chap2">一<span class="fullsp"> </span>午後の授業</div> <p>「ではみなさんは、そういうふうに川だと<ruby>言<rt>い</rt></ruby>われたり、<ruby>乳<rt>ちち</rt></ruby>の<ruby>流<rt>なが</rt></ruby>れたあとだと……</p> <ruby>言<rt>い</rt></ruby> が、例 1 の画面のルビになる。 例 4: 見た目は CSS が決めている 今度は同じ本文を横書きにしてみる。 同じ本文を横書きで 本文のウェブページは同じである。 変わったのは CSS だけである。 縦書き、ルビ、そして後で出てくる縦中横(「12」のような数字を、縦書きの中で 1 マスに横に並べる組み方)や傍点。 日本の小説に要るものは、どれも EPUB 3 では HTML と CSS の機能として書く。 EPUB 3 の概要文書にも、こうある。 EPUB 3 also supports CSS styles that enable both horizontal and vertical layout 縦書きとルビが EPUB に本格的に入ったのは、2011 年の EPUB 3.0 である。 それから十数年、3.0.1、3.1、3.2、3.3 と改訂が続いた。 3.3 からは、ウェブの標準を決めている W3C の勧告になった。 そして次が 3.4 である。 道具の素性も書いておく。 AozoraEpub3 は、2012 年に hmdev 氏が作り始めた道具である。青空文庫の注記を読んで、縦書きの EPUB を作る。 AozoraEpub3-JDK21 は、その派生版である。今の Java(Java 21)で動くようにし、Web 小説を電子書籍にする narou.rb から使えるように保守している。 本稿で「今の出力」と書くのは、AozoraEpub3-JDK21 の出力である。 ウェブページが作れるなら、電子書籍の中身も案外見慣れた世界にある。全く別の魔法ではない。 積荷の確認は、ここまで。 出港する。 出港の日: 海図を読む ■ 出港の日 天候: 晴れ/検証器は 0 件 位置: 3.3・3.4 の仕様を読み始めた 観測: 消すつもりだった要素は「消すべきもの」ではなかった 判断: 予定の航路の 2 と 3 が要らない 針路: 3.4 で何が変わるかを確かめる 予定の航路の 1 行目、仕様を読む。 計画書には、こう書いてあった。 古い 3 要素(表紙の meta・NCX・guide)を消して、警告を減らす。 EPUB 2 の時代の書き方が、今の出力に残っている。 それを消せば 3.3 対応、という算段である。 例 5: 積荷目録の実物 古い要素がどこにあるのか、実物を見ておく。 『銀河鉄道の夜』に表紙の画像を付けて、出港前の版(v1.7.6-jdk21)で変換した package.opf の抜粋である(「←」の注釈は本稿で足した)。 <metadata> <dc:title id="title">銀河鉄道の夜</dc:title> <dc:creator id="creator">宮沢賢治</dc:creator> <dc:language id="pub-lang">ja</dc:language> … <meta name="cover" content="img0000"/> ← 表紙の meta(EPUB 2 の書き方) </metadata> <manifest> <item id="nav" properties="nav" href="xhtml/nav.xhtml" media-type="application/xhtml+xml"/> <item id="sec0001" href="xhtml/0001.xhtml" media-type="application/xhtml+xml"/> … <item id="img0000" href="images/0000.png" media-type="image/png" properties="cover-image"/> <item href="toc.ncx" id="ncx" media-type="application/x-dtbncx+xml"/> ← NCX(EPUB 2 の目次) </manifest> <spine page-progression-direction="rtl" toc="ncx"> ← 右から左へめくる … </spine> <guide> ← EPUB 2 の書き方 … <reference type="text" title="sec0001" href="xhtml/0001.xhtml"/> </guide> <manifest> が、文字どおり積荷目録である。 表紙の meta、NCX、<guide>。計画書の「古い 3 要素」は、この 3 つである。 表紙は、EPUB 3 では properties="cover-image" の付いた行で示す。表紙の meta は、同じことを EPUB 2 の書き方で言っている。 <guide> は EPUB 2 の書き方で、「本文はここから」と港に教える行である。 これを消せばいい。 そう思って、EPUB 3.3 の原文を読んだ。 消す理由が無かった 3.3 の原文では、これらは deprecated(使うべきでない)ではなかった。 legacy だった。 「昔の EPUB 2 のリーダーのために残してある。入れてもよいが、EPUB 3 のリーダーは使わない」という扱いである。 しかも、EPUB Reading Systems 3.3 には、3.3 に準拠した本の legacy 要素をリーダーがサポートしてはならない、とある。 つまり作る側には「入れてもよい」、読む側には「使わないでほしい」と書かれている。 検証器も、legacy 要素があるだけでは警告しない。 念のため、手元の継続的なビルド(CI)が作っている EPUB を確かめた。 最初から 0 errors / 0 warnings だった。 減らす警告が、そもそも無い。 予定の航路の 2 行目「古い要素を消す」は、消す理由を失った。 3 行目「検証器が通ることを確かめる」は、最初から通っていた。 出港した日に、予定の 2 行が消えた。 3.4 の海図で見直したところ では、次の 3.4 はどうなのか。 3.4 の草案(2026 年 11 月 2 日版)の変更記録を読んだ。 3.4 で新しく整理されることもあれば、3.3 の時点ですでに決まっていたこともあった。 一つ目。3.4 では「outdated(古くなった機能)」という区分に整理される。 推奨はされない。でも今すぐ使えなくすると、世の中にある大量の EPUB が仕様に合わなくなる。そこで残される機能である。 さっきの legacy の要素も、この区分に含まれる。 二つ目。読む側には、以前から決まりがあった。 EPUB 3.3 の時点で「リーダーは legacy 要素を使わない」とされている。3.4 で初めて禁止されるわけではない。 だから仕様どおりなら、<guide> のような古い標識がなくても、新しい標識で本を開けるはずだった。 三つ目。縦書きの CSS にも、古い書き方が残っている。 ここは例を見たほうが早い。 例 6: 同じことを 4 回書いている CSS AozoraEpub3-JDK21 の縦中横の指定である(コメントは本稿で足した)。 .tcy span { text-combine-upright: all; /* 今の標準 */ text-combine: horizontal; /* 古い書き方 */ -webkit-text-combine: horizontal; /* ブラウザ用の古い書き方 */ -epub-text-combine: horizontal; /* EPUB 用の古い書き方 */ } 同じことを 4 回書いている。 リーダーによって、分かる書き方が違った時代の名残である。 縦書きも傍点も、同じように 3 回ずつ書いてある。 3.4 では、多くの -epub- で始まる書き方が outdated に整理される。 「CSS の対応が追いついたら、頭に何も付かない標準の書き方に移ってほしい」という方向である。 ただし最後の -epub-text-combine は別枠だ。これは 3.3 の時点ですでに deprecated だった。 四つ目。ルビの <rp> も確かめた。 <rp> は、ルビを理解しない画面のための保険である。 <ruby>漢字<rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby> ルビを理解しない画面では「漢字(かんじ)」と括弧つきで出る。 ルビを理解する画面では、<rp> は表示されない。 3.3 の時点で、EPUB 3 のリーダーはルビを理解するので <rp> は使わないほうがよい、と勧めている。3.4 でもその考え方が続いている。 AozoraEpub3-JDK21 は、もともと <rp> を出していない。 Web 小説のページに <rp> 付きのルビがあれば、取り込むときに外している。 ここは新しい変更への対応というより、今の作りを確かめ直しただけだった。 ほかにも 3.4 では、絵の形式に AVIF と JPEG XL が加わったり、漫画を縦に流して読む形式が加わったりする。 ただ、縦書きの小説には直接は効かない。 ちなみに 3.4 の作業部会の名簿には、講談社、KADOKAWA、メディアドゥ、ボイジャー、楽天といった日本の会社が並んでいる。 海図を書いている人たちの中に、縦書きの国の人がいる。 方針 ここで方針を決めた。 今作った本が、将来も安心して残せる形に。そのうえで、できるだけ過去との互換を保つ。 既定は新しい形にする。 古い形は設定で戻せるようにする。 ここまでは海図の上の話である。 ところで、海図に「古い」と書いてある航路を、実際の港は使っていないのだろうか。 海図だけでは決められない。 港に寄って確かめることにした。 予定に無い寄港である。 海図は、まだ鉛筆で書かれている。 寄港: 4 つの港に寄る ■ 寄港 天候: 晴れ/検証器は 0 件 位置: 試作の本を 4 種類作った 観測: 1 つの港だけ、古い航路を使っていた 判断: 海図で古いと書かれた行でも、消せないものがある 針路: 目次も確かめる 寄った港は 4 つである。 Apple Books(macOS) Google Play ブックス(Android) Thorium(Windows。EPUB 専用のリーダー) Kindle Previewer(Windows。Kindle で見たときの見た目を確かめる道具) 試作の本を 4 種類作った。 A: 今の形 B: 新しい形(古い要素を外したもの) C: CSS から -epub- などの古い書き方を全部外したもの N: 対照 N は「比べれば差が見えるか」を確かめるための本である。 わざと崩しておいて、4 つの港で崩れが見えることを先に確かめた。 崩した本で差が見えなければ、A と B に差が無くても「差が無い」とは言えない。 計器が動いているかを、先に確かめておく。 例 7: 縦中横 ここで縦中横を見ておく。 青空文庫のテキストでは、こう書く。 あいう!?えお |29《二十九》[#「29」は縦中横] EPUB の中では、こうなる。 <p>あいう<span class="tcy"><span>!?</span></span>えお</p> <p><span class="tcy"><span><ruby>29<rt>二十九</rt></ruby></span></span></p> そして画面では、こう出る。 「!?」と「29」が、縦の 1 マスに収まる この class="tcy" に、例 6 の 4 行が効いている。 浅瀬 1: Play ブックスは、古い航路を使っていた C の本、つまり古い書き方を全部外した本を開いた。 Apple Books、Thorium、Kindle Previewer は変わらなかった。 縦書きも、ルビも、傍点も、縦中横も、頭に何も付かない標準の書き方で出た。 Google Play ブックスだけが、縦中横を崩した。 左: -epub-text-combine あり。右: 標準の text-combine-upright だけ 切り分けるために、古い書き方を 1 種類ずつ残した本を作った。 見る前に、予測を書いておいた。 「-webkit- だけ残した本は組まれる。-epub- だけ残した本は崩れる」 両方とも外れた。 -webkit- だけの本は崩れた。 -epub- だけの本は、縦中横が組まれた。 画面は、古い書き方を全部残した本と画素まで一致した。 例 6 の最後の行、-epub-text-combine である。 3.3 の時点ですでに「使うべきでない」とされている書き方である。 これは、標準の CSS だけにすればきれいに片付く、という予想を覆した。 もう一つあった。 <guide> を外した本は、Play ブックスで開くと表紙から始まった。 <guide> がある本は、本文から始まる。 例 5 で「これを消せばいい」と思った行である。 実は EPUB 3 には、<guide> の後継がある。 目次のファイル(nav.xhtml)の中の、landmarks という一覧である。 AozoraEpub3-JDK21 は、両方を出している。 <!-- EPUB 3 の書き方(nav.xhtml の中) --> <nav epub:type="landmarks"> <ol> <li><a epub:type="bodymatter" href="0001.xhtml">本文</a></li> </ol> </nav> <!-- EPUB 2 の書き方(package.opf の中。legacy) --> <guide> <reference type="text" title="sec0001" href="xhtml/0001.xhtml"/> </guide> どちらも「本文はここから」と言っている。 <guide> を外しても、landmarks は残っていた。 それでも Play ブックスは、表紙から開いた。 <guide> だけを戻すと、本文から開いた。 新しい港には新しい標識が立っている。 少なくとも今回の Google Play ブックスは、出発地点を決めるとき、古い標識に頼っていた。 海図には「使わない」と書いてあるのに、その標識がないと出発地点が変わった。 どちらも残した。 浅瀬 2: 目次の階層は、読まれない場所にあった 港に寄ったついでに、目次パネルも見た。 『銀河鉄道の夜』の目次はこうである。 Apple Books の目次パネル 章が並んでいる。 では、「第一部」の下に「第一章」がぶら下がるような、階層のある本はどうか。 4 つの港とも、平らだった。 AozoraEpub3-JDK21 の出力では、目次の階層は NCX という EPUB 2 の目次にしか書かれていなかった。 今回の 4 つのリーダーは、EPUB 3 の目次である NAV を使っていた。 誰にも読まれない場所に、階層を書いていた。 調べると、元の AozoraEpub3 は 2012 年の時点で、NAV にも階層を書いていた。 止めたのは、私である。 2025 年 12 月、AozoraEpub3-JDK21 で、NAV の階層を出さないようにした。 少し言い訳をしておく。 私がこの派生版を触り始めたのは、2025 年である。当時は、プログラム全体の構造も EPUB の仕様も、まだよく分かっていなかった。 それなのに、目次には悩まされていた。小説によっては目次が途中で切れる。目次のせいで、正しい EPUB が作れないこともあった。 そこで、こう考えた。 「もう、フラットにしちゃえ!」 階層をなくせば、ひとまず目次で本が壊れることは避けられる。そう思って直した。 元の書き方は、こうだった。 <li><a>第一部</a></li> <li><ol> ← 見出しの無い <li> の中に、子の目次 <li><a>第1章</a></li> </ol></li> 「第一部」と、その子の「第1章」が、別々の <li> に入っている。 NAV の規則では、子の目次は、親の見出しのすぐ後ろに置く。 この形は、NAV の規則に合わなかった。 本当は、その書き方を直せばよかった。 だが当時の私は、階層を出すこと自体を止めた。 規則には合うようになった。 目次は平らになった。 階層は NCX にだけ残った。 今回のリーダーが読まない場所にしか階層を残さなかったのは、私のバグである。 1 年近く、気づかなかった。 今になって過去の自分の修正を読み返すと、正直、ちょっと恥ずかしい。 今回は、規則どおりの書き方で戻した。 <li><a>第一部 旅立ち</a> <ol> <li><a>第1章 春の朝</a></li> </ol> </li> 親の <a> のすぐ後ろに、子の <ol> を置く。 違いは、<li> の閉じる位置だけである。 左: 今までの出力。右: NAV に階層を入れた出力 予定に無い寄港、4。予定に無い修理、1。 夜の修理: 全ページ画像の本 ■ 夜の修理 天候: 晴れ/検証器は 0 件 位置: 画像だけの本を直した。検証器は黙った 観測: Apple Books が、横長のページの右端を切っている 判断: 検証器の沈黙は、無事の知らせではない 針路: ページの大きさを本全体で 1 つにする ここで航海が、予定から大きく外れる。 AozoraEpub3-JDK21 は、小説だけでなく、画像をまとめた本も作れる。 漫画やスキャンした本のように、全ページが画像の本である。 小説の話をしていたので、見る予定は無かった。 ついでに 1 冊作って、検証器にかけた。 エラーが 12 件出た。 この本を港に持って行くと、Apple Books で絵の上下が切れていた。 Apple Books。左: 直す前(上下が切れる)。右: viewport を入れた後(絵の大きさがそろった本) Google Play ブックスでは、絵が出なかった。 Google Play ブックス。左: 直す前(絵が出ない)。右: 直した後 CI の EPUB は 0 warnings だった。 CI は、画像だけの本を作っていなかった。 これは検証器の失敗ではない。 検証する本の種類を、こちらが狭くしていた。 晴れていたのは、その海域に船を出していなかったからである。 直した。晴れた。切れていた 足りなかったのは、ページの大きさの指定(viewport)だった。 画像だけの本は、1 ページ 1 枚の絵を、決まった大きさのページに置く。 その大きさを書いていなかった。 ページごとに、その絵の大きさを書いた。 <meta name="viewport" content="width=1200, height=1600"/> 各ページの <head> に入る、この 1 行である。 検証器にかけた。 0 件。晴れた。 念のため、港でも見ることにした。 ただし普通の漫画の絵では、どこが切れているのか分かりにくい。 そこで、ページの縁に赤い枠とページ番号を描いた試験用の絵を作った。 縦長、横長、正方形、極端に横に長い絵を混ぜた。 赤い枠が 4 辺とも見えていれば、切れていない。 Apple Books で開いた。 1 ページ目より横に長いページの、右端が切れていた。 極端に横に長いページでは、ページ番号ごと見えなかった。 最初は「直す前も切れていたのだから、悪化ではない」と判断した。 これは誤りだった。 同じ赤い枠の本を、直す前の版と直した後の版で作って並べた。 直す前の版では、縦長と正方形のページの下が切れていた。 直した後の版では、1 ページ目より横に長いページの右端が切れていた。 切れるページと、切れる向きが入れ替わっただけだった。 それを「同じく切れている」で片付けていた。 そこで作り直した。 ページの大きさを、本全体で 1 つにした。 縦長の絵も横長の絵も、そのページの中に、縦横の比を保ったまま置く。 4 つの港とも、赤い枠が 4 辺とも見えた。 Apple Books、赤い枠の試験用の本。上: 直す前(縦長と正方形のページの下が切れる)。中: ページごとに大きさを書いた版(1 ページ目より横に長いページの右端が切れ、極端に横長の 5 ページ目は番号ごと見えない)。下: 本全体で 1 つにした版(4 辺とも見える。絵の並びは上・中と少し違う) 検証器の 0 件は、絵が切れていないことを教えない。 仕様に合うことと、人が開いてきちんと読めることは、別の試験だった。 天候、晴れ。ただし港の桟橋が、一つ足りない。 もう一度海図へ: 消しても変わらないことを測る ■ 再出発 天候: 晴れ 位置: 古い要素を、既定で出さないことにしたい 観測: 出しても出さなくても、4 つの港で何も変わらない 判断: 既定で外す。設定で戻せるようにする 針路: 本物の積荷で、もう一度 予定の航路の 2 行目「古い要素を消す」に、ようやく戻ってきた。 ただし、全部ではない。 浅瀬 1 で、<guide> は Play ブックスが使っていると分かった。 残りの 2 つ、表紙の <meta name="cover"> と、NCX の目次(toc.ncx)はどうか。 消す前に、消しても変わらないことを測った。 消した本と消さない本を 4 つの港で開き、見比べた。 表紙(本棚のサムネイル) 開く位置 目次 最初のページ どこも変わらなかった。 Apple Books と Play ブックスでは、最初のページの画面が画素まで一致した。 小さな浅瀬が一つあった。 Kindle Previewer の本の情報の画面は、toc.ncx が無い本でも「NCX: Specified(あり)」と表示する。 表示を見て「ある」「ない」を判断すると、間違える。 表紙の meta と NCX は、既定で出さないことにした。 古いリーダーのために、設定で戻せるようにした。 確かめようのない港(Kobo)向けの出力は、今までどおり出す。 出す前に: 本物の積荷で、もう一度 ■ 出す前 天候: 晴れ/試験は全部通る 位置: 実際の作品データで、もう一度 4 つの港を回る 観測: 規格とは別の不具合が 4 つ 判断: 試験用の本では、見つからなかったものばかり 針路: 直してから出す ここまで港に持って行ったのは、試験用に作った本だった。 でも、AozoraEpub3-JDK21 は、実際の作品を電子書籍にする道具である。 ならば、本物のデータでも試しておこう。 いくつかの作品データを EPUB に変換し、4 つの港で確かめた。 同じデータを前の版でも変換して比べた。今回の変更によって、本文や画像が欠けたり、目次の項目が失われたりする問題は見つからなかった。 ところが、以前から潜んでいた別の不具合が 4 つ見つかった。 その 1: 最初の話が、2 回始まる ある作品では、最初の話が EPUB の中に 2 回入っていた。 調べると、変換元に同じ話を指すリンクが 2 つあった。 同じものを 2 回たどって、2 回取り込んでいたのである。 本の先頭から同じ話が繰り返される。読む側からすれば、何が起きたのか分からない。 その 2: 「第三話」の下に、後の話が全部ぶら下がる 本文に「第三話」と書かれた行があると、目次の上でその行が親になり、後に続く話が全部その下に入ってしまう本があった。 目次を階層表示に戻したことで、表に出た問題だった。 平らな目次なら分かりにくい。階層にすると、どの見出しを親子として扱っているかが、はっきり見えてしまう。 NAV を直したから終わり、ではなかった。今度は、目次にする見出しそのものを確かめる必要があった。 その 3: Apple Books の目次が、特定の文字で壊れる 目次の項目名に & や < があると、Apple Books の目次パネルにエラーが出た。 項目が途中で途切れ、ページ番号まで消えてしまう。 ところが、EPUB の検証器は通る。 ここは、1 か所だけ変えた本を何冊も作って切り分けた。 &、<、]]> では壊れる > や引用符では壊れない 少なくとも、XML が単純に壊れていて本を開けない、という話ではなかった。 この版では、目次の項目名に限って問題を起こす文字を全角に置き換えた。本文の文字は変えていない。 表示を回避するための処置であって、Apple Books の内部で何が起きているかまで解明できたわけではない。 左: 項目名に &。右: 全角の & その 4: 「底本:」まで目次に出てくる 作品の末尾に付いていた「底本:」で始まる行が、見出しとして扱われていた。 しかも、一部の作品だけではない。調べた本の目次に、同じように現れていた。 本文の情報として残ることと、読者が選ぶ目次の項目になることは違う。 これも、試験用の本では気づかなかった。 4 つとも直した。 どれも、最初に用意した試験用の本では見つからなかったものだった。 本物の積荷は、試験の積荷より重い。 こうして v1.8.0-jdk21 を出した。 その夜、スマホで文字を大きくすると表題ページで題名と著者が重なる、という不具合も直した。 帰港 ■ 帰港 位置: v1.8.0-jdk21 を出した 予定の航路: 4 行 実際の航路: 下のとおり 出港前の予定は、4 行だった。 予定の航路 1. 仕様を読む 2. 古い要素を消す 3. 検証器が通ることを確かめる 4. 出す 実際の航路は、こうなった。 実際の航路 1. 仕様を読む → 消す理由が無かった。検証器は最初から 0 件だった 2. 4 つの港に寄る → Play ブックスだけ、古い縦中横の書き方と <guide> に頼っていた → 目次の階層を、今回のリーダーが読まない場所に書いていた(私のバグ) 3. 予定に無かった、全ページ画像の本の修理 → 検証器のエラー 12 件。直して 0 件。それでも Apple Books で横長のページが切れていた 4. 表紙の meta と NCX を消しても変わらないことを、4 つの港で測る 5. 本物の積荷で、もう一度 → 規格と関係ない不具合が 4 つ 6. v1.8.0 を出す 日本の縦書きの小説について、分かったことをまとめておく。 縦書き・ルビ・傍点は、頭に何も付かない標準の CSS で、4 つの港とも出た。古い書き方も、当面は並べて書いておく 縦中横は、Play ブックスがまだ「使うべきでない」とされる書き方に頼っている。だから残す 表紙の meta と NCX(EPUB 2 の名残)は、出さなくても 4 つの港で何も変わらなかった。既定で外し、設定で戻せる <guide> も EPUB 2 の名残だが、Play ブックスが開く位置に使っている。EPUB 3.3 の仕様では使わないはずなのに、実機のために今は残す 航海で分かったことは、3 つである。 海図(仕様の区分)は、港(リーダー)が何に頼っているかを教えない。 晴れ(検証器の 0 件)は、積荷が無事だという知らせではない。 本物の積荷には、試験用の本が教えてくれないことがある。 検証器が 0 件と言っていたのは、検証器に渡した本についてである。画像だけの本を渡していなかったのは、こちらだった。 前回の RAW の話では「一番大きい JPEG を探せばいい」という前提が崩れた。 今回は「仕様どおりなら読めるはずだ」という前提が崩れた。 どちらも、実物を開かなければ分からなかった。 ここまで書くと、ひどい目に遭ったように見えるかもしれない。 でも、面白かった。 お客さんや営業が、後ろに立っていないからである(笑)。 仕事なら「いつ直るの?」と聞かれそうなところでも、今回は「なんでこうなるんだろう?」と寄り道できる。予想が外れたら、別の本を作って、また試せばいい。 昔の自分が直したはずのところで転んだのは、少し恥ずかしかったけれど。 港にも事情がある 4 つの港を回って、もう一つ思ったことがある。 リーダーを作っている人たちも、大変なのだろう。 EPUB の仕様は更新されていく。 一方で、一度出版された電子書籍は、そのまま何年も、何十年も残る。紙の本と同じで、古いからといって、読む人の本棚から消えるわけではない。 読む側にしてみれば、昨日まで読めていた本が、リーダーの更新で急に読めなくなったら困る。 新しい仕様には対応しなければならない。でも、昔の本も読み続けられるようにしたい。 この両立は、なかなか難しいはずだ。 今回見つけた違いが、すべて互換性のために意図されたものとは限らない。単に未対応のところも、不具合もあるだろう。各社の事情までは分からない。 それでも、Google Play ブックスが古い指定を使っていたことは、最初とは少し違って見える。 そういうこちらも、最初は古い要素を消すつもりで出港して、結局、いくつか残して帰ってきた。 船を作る側にも、港を守る側にも、事情がある。 そういえば、EPUB の中身は ZIP だった。 もし手元に、自由に開ける EPUB があったら、一度中をのぞいてみてほしい。 本文の HTML や CSS が、そこに入っている。 普段何気なく読んでいる電子書籍も、こうして作られている。 EPUB 3.4 は、まだ草案である。 海図は、これからも書き換わる。 そのときは、また港に寄ってみよう。 短い航海の予定だった。 資料: 海図の細かい文字が気になる人へ ここからは、試した環境と原典である。物語はもう帰港している。 寄った港の版: Apple Books(macOS)、Google Play ブックス 2026.10.2.1(Android)、Thorium 3.5.1(Windows)、Kindle Previewer 4.0.3.0(Windows。Tablet・Phone・E-reader)。v1.8.1-jdk21 を出した後、Send to Kindle 経由で Kindle 端末と iPhone の Kindle アプリでも確かめた 寄っていない港: Kobo(端末が無い)。Kobo 向けの出力は今までの形を残している 検証器: epubcheck 5.3.0 と 5.4.0 EPUB の仕様(W3C) EPUB 3.3(2026 年 1 月 13 日の勧告版) 5.9 Legacy features: 表紙の meta、<guide>、NCX 6.1.4.3.2 The rp element 6.3.1.3 Prefixed properties(-epub- で始まる CSS) Expressing the ICB in XHTML(画像だけの本の viewport) EPUB Reading Systems 3.3 5.7 Legacy features(「Reading systems MUST NOT support legacy features……」) EPUB 3.4(2026 年 11 月 2 日の候補勧告草案。最新版) EPUB Reading Systems 3.4 EPUB 3 Overview 3.4 の予定: Publishing Maintenance Working Group Charter(「2027 February: EPUB 3.4 final version」) EPUB が借りている、ウェブの仕様 CSS Writing Modes Level 3(縦書き。縦中横は text-combine-upright) HTML の ruby 要素(<rt> と <rp>) 日本語組版処理の要件(W3C。縦書き・ルビ・縦中横などを、日本語の組版の側から整理した文書) 道具と作品 EPUB 3.3 と 3.4 への対応(要素ごとの一覧と、リーダーごとの違い) 最新版(v1.8.1-jdk21) AozoraEpub3(hmdev 氏の原作) AozoraEpub3-JDK21 青空文庫の注記一覧([#…] や 《》 の書き方) 例に使った作品: 宮沢賢治『銀河鉄道の夜』(青空文庫)
today is a sad day for the web, and for a11y. but on the bright side, hopefully this is the last time i'll have to explain why buttons and controls should not have `cursor: pointer` alas, it is tiring trying to help you dorks. csswg-drafts#14251
Why your INP can fall after one more click By Aditya Reddy Say you have a page with 49 interactions. The slowest took 480 ms, followed by 320 ms and 240 ms. Everything else was faster. Your INP is 480 ms. Now click something fast. You have changed no code, and that slow interaction still takes 480 ms. The score for the visit drops to 320 ms anyway. That's the part of INP I wanted to explain in my Event Timing specification PR. The PR is still open. A single number on a dashboard hides quite a bit of bookkeeping. What happened at click 50? INP skips one longest interaction for every complete group of 50 interactions. At 49, it picks the longest. At 50, it picks the second longest. The INP guide explains the reason for excluding these outliers on pages with lots of interactions. For our example, assuming all the extra interactions are below 240 ms: | Interactions so far | Longest values skipped | INP candidate | | --- | --- | --- | | 49 | 0 | 480 ms | | 50 | 1 | 320 ms | | 99 | 1 | 320 ms | | 100 | 2 | 240 ms | So you can cross two of these boundaries and go from 480 to 240 ms without fixing the 480 ms interaction. Nice dashboard, same slow click. The selection code in web-vitals amounts to this, with the latencies sorted from longest to shortest: const skip = Math.floor(interactionCount / 50); const index = Math.min(skip, descendingLatencies.length - 1); const candidate = descendingLatencies[index]; You need at least one candidate for this fragment. It is only the selection step, not a complete collector. web-vitals retains up to ten of the longest interactions, so the list can be much shorter than the interaction count. Count interactions, not event entries There is another place to get the numbers wrong before you even reach that selection. A click can leave several PerformanceEventTiming entries. Take these made-up values from one visit: | Interaction ID | Event | Duration | | --- | --- | --- | | 7 | pointerdown | 320 ms | | 7 | pointerup | 296 ms | | 7 | click | 280 ms | | 14 | click | 480 ms | | 21 | keydown | 240 ms | The first three rows belong to one interaction. Its latency is 320 ms, the longest duration in that group. Adding them gives 896 ms, but those durations overlap. Five rows here give us three interactions, with latencies of 320, 480 and 240 ms. Group by the nonzero interactionId. Leave entries with ID zero out of this calculation. The Event Timing spec describes how those IDs group related events. Your observer may have missed some of it For entries of type event, the default duration threshold is 104 ms. You can register an observer with durationThreshold: 16 to receive shorter events going forward. That does not bring back all the fast events from before registration. Even with buffered delivery, you only get the earlier entries that were kept under the default threshold. This is covered in the spec's measurement section. That's why the total interaction count and your observed list length need to stay separate. A fast interaction can increase the count without appearing in that list. Lowering the threshold late won't recover its duration. I would use web-vitals for production collection. There is first-input handling, page visibility and back/forward cache restoration to account for too. Code running in one window also cannot see every interaction in an embedded frame. When debugging the score, keep the selected interaction's ID and latency along with the total count. If the score drops, check which interaction it picked. And if you are fixing that 480 ms click, compare the click itself before and after the change under similar conditions. The proposed spec section puts the grouping, selection, observer threshold and retained-list limit together. The fast click at interaction 50 is a useful example of why those details matter.
you are all so fucking dumb 🤦♂️ csswg-drafts#14251
buttonのcursorがデフォルトでpointerになるとか、公式が勝手に言ってるだけだろ! csswg-drafts#14251
buttons, selects and checkboxes are getting the hand cursor by default. interesting 🎭 csswg-drafts#14251 t.co
The debate is over, and evil has won. `cursor: pointer` now means clickable. RIP my ability to tell whether ⌘-clicking something will open it in a new window. csswg-drafts#14251