Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| 8 |
| /docs/options/<name>.md one file per option, geometry field and method |
| 9 | /docs/examples/<id>.md copy-paste examples |
| 10 | /options.json the reference as JSON |
| 11 |
| 12 | Run `npm run docs` after changing site/api.ts; `npm test` fails while |
| 13 | the files are out of date. */ |
| 14 |
| 15 | import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; |
| 16 | import { dirname, join } from "node:path"; |
| 17 | import { fileURLToPath } from "node:url"; |
| 18 | import { |
| 19 | EXAMPLES, |
| 20 | GROUPS, |
| 21 | INSTALL, |
| 22 | NPM_URL, |
| 23 | PACKAGE, |
| 24 | RULES, |
| 25 | SITE_URL, |
| 26 | SOURCE_URL, |
| 27 | SUMMARY, |
| 28 | VERSION, |
| 29 | type ApiEntry, |
| 30 | type ApiGroup, |
| 31 | type Example, |
| 32 | } from "../site/api.ts"; |
| 33 | import { entryPath } from "../site/slug.ts"; |
| 34 |
| 35 |
| 36 | const fence = (language: string, code: string) => `\`\`\`${language}\n${code}\n\`\`\``; |
| 37 |
| 38 | function entryText(group: ApiGroup, entry: ApiEntry) { |
| 39 | const lines = [`### ${entry.name}`, "", `- Type: \`${entry.type}\``]; |
| 40 | if (entry.default) lines.push(`- Default: ${entry.default.startsWith("\"") || /^[\d.]+$|^true$|^false$|^null$/.test(entry.default) ? `\`${entry.default}\`` : entry.default}`); |
| 41 | lines.push(`- Group: ${group.title}`, "", entry.description); |
| 42 | if (entry.example) lines.push("", fence(entry.example.startsWith("<") ? "tsx" : "ts", entry.example)); |
| 43 | return lines.join("\n"); |
| 44 | } |
| 45 |
| 46 | function groupText(group: ApiGroup) { |
| 47 | return [`## ${group.title}`, "", group.intro, "", ...group.entries.map(entry => entryText(group, entry))].join("\n\n").replace(/\n{3,}/g, "\n\n"); |
| 48 | } |
| 49 |
| 50 | function exampleText(example: Example, level = 2) { |
| 51 | return [`${"#".repeat(level)} ${example.title}`, "", example.description, "", fence(example.language, example.code)].join("\n"); |
| 52 | } |
| 53 |
| 54 | const header = [ |
| 55 | `# ${PACKAGE}`, |
| 56 | "", |
| 57 | `> ${SUMMARY}`, |
| 58 | "", |
| 59 | `Version ${VERSION}. Install: \`${INSTALL}\`. npm: ${NPM_URL}. Source: ${SOURCE_URL}. Playground: ${SITE_URL}.`, |
| 60 | ].join("\n"); |
| 61 |
| 62 | const quickstart = [ |
| 63 | "## Quick start", |
| 64 | "", |
| 65 | fence("sh", INSTALL), |
| 66 | "", |
| 67 | fence("tsx", `import { LiquidGlass } from "${PACKAGE}/react"; |
| 68 | import "${PACKAGE}/styles.css"; |
| 69 |
| 70 | <nav className="relative rounded-full px-6 py-3"> |
| 71 | <LiquidGlass appearance="class" /> |
| 72 | … |
| 73 | </nav>`), |
| 74 | "", |
| 75 | "## Rules", |
| 76 | "", |
| 77 | ...RULES.map(rule => `- ${rule}`), |
| 78 | ].join("\n"); |
| 79 |
| 80 | export function render() { |
| 81 | const files = new Map<string, string>(); |
| 82 | const optionFiles: { path: string; title: string; summary: string }[] = []; |
| 83 |
| 84 | for (const group of GROUPS) { |
| 85 | for (const entry of group.entries) { |
| 86 | const path = entryPath(group.id, entry.name); |
| 87 | files.set(path, `${header}\n\n${entryText(group, entry)}\n`); |
| 88 | optionFiles.push({ path, title: `${group.title}: ${entry.name}`, summary: entry.description.split(". ")[0]?.replace(/\.$/, "") ?? "" }); |
| 89 | } |
| 90 | } |
| 91 | for (const example of EXAMPLES) { |
| 92 | files.set(`docs/examples/${example.id}.md`, `${header}\n\n${exampleText(example)}\n`); |
| 93 | } |
| 94 |
| 95 | const reference = GROUPS.map(groupText).join("\n\n"); |
| 96 | const examples = EXAMPLES.map(example => exampleText(example)).join("\n\n"); |
| 97 | files.set("docs/api.md", `${header}\n\n${quickstart}\n\n${reference}\n`); |
| 98 |
| 99 | files.set( |
| 100 | "llms.txt", |
| 101 | [ |
| 102 | header, |
| 103 | "", |
| 104 | "Liquid Glass is a material, not a component library: put `<LiquidGlass />` first inside any positioned, rounded element and it becomes glass. Every option below has its own plain-text page.", |
| 105 | "", |
| 106 | "## Docs", |
| 107 | "", |
| 108 | `- [Agents guide](${SITE_URL}/agents.md): how to use the package correctly, with the rules that keep the glass from blanking`, |
| 109 | `- [Full reference](${SITE_URL}/docs/api.md): every option, geometry field, method and export`, |
| 110 | `- [Everything in one file](${SITE_URL}/llms-full.txt)`, |
| 111 | `- [Reference as JSON](${SITE_URL}/options.json)`, |
| 112 | "", |
| 113 | "## Examples", |
| 114 | "", |
| 115 | ...EXAMPLES.map(example => `- [${example.title}](${SITE_URL}/docs/examples/${example.id}.md): ${example.description}`), |
| 116 | "", |
| 117 | "## Options", |
| 118 | "", |
| 119 | ...optionFiles.map(file => `- [${file.title}](${SITE_URL}/${file.path}): ${file.summary}`), |
| 120 | "", |
| 121 | ].join("\n"), |
| 122 | ); |
| 123 |
| 124 | files.set("llms-full.txt", `${header}\n\n${quickstart}\n\n${reference}\n\n# Examples\n\n${examples}\n`); |
| 125 |
| 126 | files.set( |
| 127 | "agents.md", |
| 128 | [ |
| 129 | `# Using ${PACKAGE}`, |
| 130 | "", |
| 131 | `For coding agents adding Liquid Glass to a web app. ${SUMMARY}`, |
| 132 | "", |
| 133 | `Version ${VERSION}. Every option has a plain-text page under ${SITE_URL}/docs/options/; the index is ${SITE_URL}/llms.txt.`, |
| 134 | "", |
| 135 | "## Install", |
| 136 | "", |
| 137 | fence("sh", INSTALL), |
| 138 | "", |
| 139 | "Import the stylesheet once, at the app's root:", |
| 140 | "", |
| 141 | fence("ts", `import "${PACKAGE}/styles.css";`), |
| 142 | "", |
| 143 | "## Pick the entry point", |
| 144 | "", |
| 145 | "- React: `import { LiquidGlass, useLiquidGlass, useLiquidGlassTabs } from \"@colineapp/liquid-glass/react\"`.", |
| 146 | "- Anything else: `import { createLiquidGlass, attachLiquidGlassTabs } from \"@colineapp/liquid-glass\"`.", |
| 147 | "- With a class-based dark mode (Tailwind `dark:`, next-themes), pass `appearance=\"class\"`. Otherwise the glass follows prefers-color-scheme.", |
| 148 | "", |
| 149 | "## Pick the options", |
| 150 | "", |
| 151 | "| Surface | Options |", |
| 152 | "| --- | --- |", |
| 153 | "| Nav bar, toolbar, card | none, or `interactive` if it holds controls |", |
| 154 | "| Search field, button you press | `interactive swell` |", |
| 155 | "| Menu, popover | `floating`, and `materialize()` on open |", |
| 156 | "| Dialog, sheet | `floating=\"dialog\"`, `fit=\"scroll\"` if it scrolls itself |", |
| 157 | "| Glass over a photo or video | `variant=\"clear\"` |", |
| 158 | "| Tinted glass | `hue=\"#3d6bff\"` |", |
| 159 | "| Segmented control, tab bar | `useLiquidGlassTabs(trackRef)` on your own tabs |", |
| 160 | "", |
| 161 | "## Rules that keep the glass working", |
| 162 | "", |
| 163 | ...RULES.map(rule => `- ${rule}`), |
| 164 | "", |
| 165 | "## Checking your work", |
| 166 | "", |
| 167 | "- The surface element has `data-liquid-glass=\"dark\"` or `\"light\"` once the glass is attached, and its glass child has `data-ready` once the rim is built.", |
| 168 | "- In Chromium the glass child also has `data-refraction`. If the glass looks flat grey or empty, an ancestor has opacity, a filter, a mask or a backdrop-filter.", |
| 169 | "- Don't put a background colour on the surface to make it look glassy: the glass is the background.", |
| 170 | "", |
| 171 | "## Examples", |
| 172 | "", |
| 173 | EXAMPLES.map(example => exampleText(example, 3)).join("\n\n"), |
| 174 | "", |
| 175 | ].join("\n"), |
| 176 | ); |
| 177 |
| 178 | files.set( |
| 179 | "options.json", |
| 180 | `${JSON.stringify( |
| 181 | { |
| 182 | package: PACKAGE, |
| 183 | version: VERSION, |
| 184 | install: INSTALL, |
| 185 | summary: SUMMARY, |
| 186 | rules: RULES, |
| 187 | groups: GROUPS, |
| 188 | examples: EXAMPLES, |
| 189 | }, |
| 190 | null, |
| 191 | 2, |
| 192 | )}\n`, |
| 193 | ); |
| 194 | return files; |
| 195 | } |
| 196 |
| 197 | const publicDir = join(dirname(fileURLToPath(import.meta.url)), "..", "site", "public"); |
| 198 |
| 199 | export function write() { |
| 200 | for (const dir of ["docs"]) rmSync(join(publicDir, dir), { recursive: true, force: true }); |
| 201 | for (const [path, content] of render()) { |
| 202 | const target = join(publicDir, path); |
| 203 | mkdirSync(dirname(target), { recursive: true }); |
| 204 | writeFileSync(target, content); |
| 205 | } |
| 206 | } |
| 207 |
| 208 | /** Generated files on disk, for the freshness test. */ |
| 209 | export function listWritten() { |
| 210 | const found: string[] = []; |
| 211 | const walk = (dir: string, prefix: string) => { |
| 212 | for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 213 | if (entry.isDirectory()) walk(join(dir, entry.name), `${prefix}${entry.name}/`); |
| 214 | else found.push(`${prefix}${entry.name}`); |
| 215 | } |
| 216 | }; |
| 217 | walk(join(publicDir, "docs"), "docs/"); |
| 218 | return found; |
| 219 | } |
| 220 |
| 221 | export { publicDir }; |
| 222 |
| 223 | if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { |
| 224 | write(); |
| 225 | console.log(`Wrote ${render().size} files to site/public.`); |
| 226 | } |
| 227 |