├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── ARCHITECTURE.md ├── LICENSE.md ├── README.md ├── cmdk ├── package.json ├── src │ ├── command-score.ts │ └── index.tsx └── tsup.config.ts ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── test ├── basic.test.ts ├── dialog.test.ts ├── group.test.ts ├── item.test.ts ├── keybind.test.ts ├── next-env.d.ts ├── numeric.test.ts ├── package.json ├── pages │ ├── _app.tsx │ ├── dialog.tsx │ ├── group.tsx │ ├── huge.tsx │ ├── index.tsx │ ├── item-advanced.tsx │ ├── item.tsx │ ├── keybinds.tsx │ ├── numeric.tsx │ ├── portal.tsx │ └── props.tsx ├── props.test.ts ├── style.css └── tsconfig.json ├── tsconfig.json └── website ├── .eslintrc.json ├── .gitignore ├── README.md ├── components ├── cmdk │ ├── framer.tsx │ ├── linear.tsx │ ├── raycast.tsx │ └── vercel.tsx ├── code │ ├── code.module.scss │ └── index.tsx ├── icons │ ├── icons.module.scss │ └── index.tsx └── index.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── _document.tsx └── index.tsx ├── public ├── favicon.svg ├── grid.svg ├── inter-var-latin.woff2 ├── line.svg ├── og.png ├── paco.png ├── rauno.jpeg ├── robots.txt └── vercel.svg ├── styles ├── cmdk │ ├── framer.scss │ ├── linear.scss │ ├── raycast.scss │ └── vercel.scss ├── globals.scss └── index.module.scss ├── tsconfig.json └── vercel.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pacocoursey 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run E2E tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: pnpm/action-setup@v4 # respects packageManager in package.json 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | cache: 'pnpm' 21 | 22 | - run: pnpm install 23 | env: 24 | CI: true 25 | 26 | - run: pnpm build 27 | - run: pnpm test:format 28 | - run: pnpm playwright install --with-deps 29 | - run: pnpm test || exit 1 30 | 31 | - name: Upload test results 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: playwright-report 35 | path: playwright-report.json 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .env 4 | .env.local 5 | .env.development 6 | .env.development.local 7 | *.log 8 | yalc.lock 9 | 10 | .vercel/ 11 | .turbo/ 12 | .next/ 13 | .yalc/ 14 | build/ 15 | dist/ 16 | node_modules/ 17 | .vercel 18 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | dist 3 | pnpm-lock.yaml 4 | .pnpm-store 5 | .vercel 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | tabWidth: 2, 5 | trailingComma: 'all', 6 | printWidth: 120, 7 | } 8 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | > Document is a work in progress! 4 | 5 | ⌘K is born from a simple constraint: can you write a combobox with filtering and sorting using the [compound component](https://kentcdodds.com/blog/compound-components-with-react-hooks) approach? We didn't want to render items manually from an array: 6 | 7 | ```tsx 8 | // No 9 | <> 10 | {items.map((item) => { 11 | return
{item}
12 | })} 13 | 14 | ``` 15 | 16 | We didn't want to provide a render prop: 17 | 18 | ```tsx 19 | // No 20 | onItemRender={({ item }) => { 21 | return
{item}
22 | }} 23 | ``` 24 | 25 | Instead, we wanted to render components: 26 | 27 | ```tsx 28 | // Yes 29 | My item 30 | ``` 31 | 32 | Especially, we wanted full component composition: 33 | 34 | ```tsx 35 | // YES 36 | <> 37 | 38 | {staticItems} 39 | 40 | ``` 41 | 42 | Compound components are natural and easy to write. A few months after exploring this library, we were pleased to see [Radix UI](https://www.radix-ui.com) released using this exact approach of component structure – setting the standard for ease of use and composability. 43 | 44 | However, for a combobox, it is a terrible, terrible constraint that we've spent 2 years fighting. 45 | 46 | ## Approach 47 | 48 | ⌘K always keeps every item and group rendered in the React tree. Each item and group adds or removes itself from the DOM based on the search input. The DOM is authoritative. Item selection order is based on the DOM order, which is based on the React render order, which the consumer provides. 49 | 50 | ### Discarded approach 51 | 52 | We did not use `React.Children` iteration because it will not support component composition. There is no way to "peek inside" the items contained within ``, so those items cannot be filtered. 53 | 54 | We did not use an object-based data array for each item, like `{ name: "Logout", action: () => logout() }` because this is strict and limiting. In reality, the interface of those objects grows with edge-cases, like `image`, `detailedSubTitle`, `hideWhenRootSearch`, etc. We prefer that you have full control of item rendering, including icons, keyboard shortcuts, and styling. Don't want an item shown? Don't render it. Only want to show an item under condition xyz? Render it. 55 | 56 | We did not use a render prop because they are an inelegant pattern and quickly fall to long, centralised if-else logic chains. For example, if you want a fancy sparkle rainbow item, you need a new if statement to render that item specially. 57 | 58 | The original approach for tracking which item was selected was to keep an index 0..n. But it's impossible to know which Item is in which position within the React tree when React Strict Mode is enabled, because `useEffect` runs twice and `useRef` cannot be used for stable IDs. This may be possible with `useId`, now. We created [use-descendants](https://github.com/pacocoursey/use-descendants) to track relative component indeces, but abandoned it because it could not work in Strict Mode, and will be incompatible with upcoming concurrent mode. Now, we track the selected item with its value, because it is stable across item mounts and unmounts. 59 | 60 | ## Example 61 | 62 | ```tsx 63 | 64 | 65 | A 66 | B 67 | 68 | ``` 69 | 70 | The "A" item should not be shown! But we cannot remove it from the React tree, because the user controls it. In most cases, this is easy because the rendered items is sourced from a backing data array: 71 | 72 | ```tsx 73 | <> 74 | {['A', 'B'].map((item) => { 75 | if (matches(item, search)) { 76 | return {item} 77 | } 78 | })} 79 | 80 | ``` 81 | 82 | But in our case, the item will remain in the React tree and just be removed from the DOM: 83 | 84 | ```tsx 85 | 86 | {/* returns `null`, no DOM created */} 87 | A 88 | B 89 | 90 | ``` 91 | 92 | ## Performance 93 | 94 | This is more expensive memory wise, because if there are 2,000 items but the list is filtered to only 2 items, we still allocate memory for 2,000 instances of the Item component. But it's our only option! Thankfully we can still keep the DOM size to 2 items. 95 | 96 | ## Groups 97 | 98 | Item mount informs both the root and the parent group, which keeps track of items within it. Each group informs the root. 99 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Paco Coursey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # ⌘K [![cmdk minzip package size](https://img.shields.io/bundlephobia/minzip/cmdk)](https://www.npmjs.com/package/cmdk?activeTab=code) [![cmdk package version](https://img.shields.io/npm/v/cmdk.svg?colorB=green)](https://www.npmjs.com/package/cmdk) 6 | 7 | ⌘K is a command menu React component that can also be used as an accessible combobox. You render items, it filters and sorts them automatically. ⌘K supports a fully composable API [How?](/ARCHITECTURE.md), so you can wrap items in other components or even as static JSX. 8 | 9 | Demo and examples: [cmdk.paco.me](https://cmdk.paco.me) 10 | 11 | ## Install 12 | 13 | ```bash 14 | pnpm install cmdk 15 | ``` 16 | 17 | ## Use 18 | 19 | ```tsx 20 | import { Command } from 'cmdk' 21 | 22 | const CommandMenu = () => { 23 | return ( 24 | 25 | 26 | 27 | No results found. 28 | 29 | 30 | a 31 | b 32 | 33 | c 34 | 35 | 36 | Apple 37 | 38 | 39 | ) 40 | } 41 | ``` 42 | 43 | Or in a dialog: 44 | 45 | ```tsx 46 | import { Command } from 'cmdk' 47 | 48 | const CommandMenu = () => { 49 | const [open, setOpen] = React.useState(false) 50 | 51 | // Toggle the menu when ⌘K is pressed 52 | React.useEffect(() => { 53 | const down = (e) => { 54 | if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 55 | e.preventDefault() 56 | setOpen((open) => !open) 57 | } 58 | } 59 | 60 | document.addEventListener('keydown', down) 61 | return () => document.removeEventListener('keydown', down) 62 | }, []) 63 | 64 | return ( 65 | 66 | 67 | 68 | No results found. 69 | 70 | 71 | a 72 | b 73 | 74 | c 75 | 76 | 77 | Apple 78 | 79 | 80 | ) 81 | } 82 | ``` 83 | 84 | ## Parts and styling 85 | 86 | All parts forward props, including `ref`, to an appropriate element. Each part has a specific data-attribute (starting with `cmdk-`) that can be used for styling. 87 | 88 | ### Command `[cmdk-root]` 89 | 90 | Render this to show the command menu inline, or use [Dialog](#dialog-cmdk-dialog-cmdk-overlay) to render in a elevated context. Can be controlled with the `value` and `onValueChange` props. 91 | 92 | > **Note** 93 | > 94 | > Values are always trimmed with the [trim()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim) method. 95 | 96 | ```tsx 97 | const [value, setValue] = React.useState('apple') 98 | 99 | return ( 100 | 101 | 102 | 103 | Orange 104 | Apple 105 | 106 | 107 | ) 108 | ``` 109 | 110 | You can provide a custom `filter` function that is called to rank each item. Note that the value will be trimmed. 111 | 112 | ```tsx 113 | { 115 | if (value.includes(search)) return 1 116 | return 0 117 | }} 118 | /> 119 | ``` 120 | 121 | A third argument, `keywords`, can also be provided to the filter function. Keywords act as aliases for the item value, and can also affect the rank of the item. Keywords are trimmed. 122 | 123 | ```tsx 124 | { 126 | const extendValue = value + ' ' + keywords.join(' ') 127 | if (extendValue.includes(search)) return 1 128 | return 0 129 | }} 130 | /> 131 | ``` 132 | 133 | Or disable filtering and sorting entirely: 134 | 135 | ```tsx 136 | 137 | 138 | {filteredItems.map((item) => { 139 | return ( 140 | 141 | {item} 142 | 143 | ) 144 | })} 145 | 146 | 147 | ``` 148 | 149 | You can make the arrow keys wrap around the list (when you reach the end, it goes back to the first item) by setting the `loop` prop: 150 | 151 | ```tsx 152 | 153 | ``` 154 | 155 | ### Dialog `[cmdk-dialog]` `[cmdk-overlay]` 156 | 157 | Props are forwarded to [Command](#command-cmdk-root). Composes Radix UI's Dialog component. The overlay is always rendered. See the [Radix Documentation](https://www.radix-ui.com/docs/primitives/components/dialog) for more information. Can be controlled with the `open` and `onOpenChange` props. 158 | 159 | ```tsx 160 | const [open, setOpen] = React.useState(false) 161 | 162 | return ( 163 | 164 | ... 165 | 166 | ) 167 | ``` 168 | 169 | You can provide a `container` prop that accepts an HTML element that is forwarded to Radix UI's Dialog Portal component to specify which element the Dialog should portal into (defaults to `body`). See the [Radix Documentation](https://www.radix-ui.com/docs/primitives/components/dialog#portal) for more information. 170 | 171 | ```tsx 172 | const containerElement = React.useRef(null) 173 | 174 | return ( 175 | <> 176 | 177 |
178 | 179 | ) 180 | ``` 181 | 182 | ### Input `[cmdk-input]` 183 | 184 | All props are forwarded to the underlying `input` element. Can be controlled with the `value` and `onValueChange` props. 185 | 186 | ```tsx 187 | const [search, setSearch] = React.useState('') 188 | 189 | return 190 | ``` 191 | 192 | ### List `[cmdk-list]` 193 | 194 | Contains items and groups. Animate height using the `--cmdk-list-height` CSS variable. 195 | 196 | ```css 197 | [cmdk-list] { 198 | min-height: 300px; 199 | height: var(--cmdk-list-height); 200 | max-height: 500px; 201 | transition: height 100ms ease; 202 | } 203 | ``` 204 | 205 | To scroll item into view earlier near the edges of the viewport, use scroll-padding: 206 | 207 | ```css 208 | [cmdk-list] { 209 | scroll-padding-block-start: 8px; 210 | scroll-padding-block-end: 8px; 211 | } 212 | ``` 213 | 214 | ### Item `[cmdk-item]` `[data-disabled?]` `[data-selected?]` 215 | 216 | Item that becomes active on pointer enter. You should provide a unique `value` for each item, but it will be automatically inferred from the `.textContent`. 217 | 218 | ```tsx 219 | console.log('Selected', value)} 221 | // Value is implicity "apple" because of the provided text content 222 | > 223 | Apple 224 | 225 | ``` 226 | 227 | You can also provide a `keywords` prop to help with filtering. Keywords are trimmed. 228 | 229 | ```tsx 230 | Apple 231 | ``` 232 | 233 | ```tsx 234 | console.log('Selected', value)} 236 | // Value is implicity "apple" because of the provided text content 237 | > 238 | Apple 239 | 240 | ``` 241 | 242 | You can force an item to always render, regardless of filtering, by passing the `forceMount` prop. 243 | 244 | ### Group `[cmdk-group]` `[hidden?]` 245 | 246 | Groups items together with the given `heading` (`[cmdk-group-heading]`). 247 | 248 | ```tsx 249 | 250 | Apple 251 | 252 | ``` 253 | 254 | Groups will not unmount from the DOM, rather the `hidden` attribute is applied to hide it from view. This may be relevant in your styling. 255 | 256 | You can force a group to always render, regardless of filtering, by passing the `forceMount` prop. 257 | 258 | ### Separator `[cmdk-separator]` 259 | 260 | Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. 261 | 262 | ### Empty `[cmdk-empty]` 263 | 264 | Automatically renders when there are no results for the search query. 265 | 266 | ### Loading `[cmdk-loading]` 267 | 268 | You should conditionally render this with `progress` while loading asynchronous items. 269 | 270 | ```tsx 271 | const [loading, setLoading] = React.useState(false) 272 | 273 | return {loading && Hang on…} 274 | ``` 275 | 276 | ### `useCommandState(state => state.selectedField)` 277 | 278 | Hook that composes [`useSyncExternalStore`](https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore). Pass a function that returns a slice of the command menu state to re-render when that slice changes. This hook is provided for advanced use cases and should not be commonly used. 279 | 280 | A good use case would be to render a more detailed empty state, like so: 281 | 282 | ```tsx 283 | const search = useCommandState((state) => state.search) 284 | return No results found for "{search}". 285 | ``` 286 | 287 | ## Examples 288 | 289 | Code snippets for common use cases. 290 | 291 | ### Nested items 292 | 293 | Often selecting one item should navigate deeper, with a more refined set of items. For example selecting "Change theme…" should show new items "Dark theme" and "Light theme". We call these sets of items "pages", and they can be implemented with simple state: 294 | 295 | ```tsx 296 | const ref = React.useRef(null) 297 | const [open, setOpen] = React.useState(false) 298 | const [search, setSearch] = React.useState('') 299 | const [pages, setPages] = React.useState([]) 300 | const page = pages[pages.length - 1] 301 | 302 | return ( 303 | { 305 | // Escape goes to previous page 306 | // Backspace goes to previous page when search is empty 307 | if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) { 308 | e.preventDefault() 309 | setPages((pages) => pages.slice(0, -1)) 310 | } 311 | }} 312 | > 313 | 314 | 315 | {!page && ( 316 | <> 317 | setPages([...pages, 'projects'])}>Search projects… 318 | setPages([...pages, 'teams'])}>Join a team… 319 | 320 | )} 321 | 322 | {page === 'projects' && ( 323 | <> 324 | Project A 325 | Project B 326 | 327 | )} 328 | 329 | {page === 'teams' && ( 330 | <> 331 | Team 1 332 | Team 2 333 | 334 | )} 335 | 336 | 337 | ) 338 | ``` 339 | 340 | ### Show sub-items when searching 341 | 342 | If your items have nested sub-items that you only want to reveal when searching, render based on the search state: 343 | 344 | ```tsx 345 | const SubItem = (props) => { 346 | const search = useCommandState((state) => state.search) 347 | if (!search) return null 348 | return 349 | } 350 | 351 | return ( 352 | 353 | 354 | 355 | Change theme… 356 | Change theme to dark 357 | Change theme to light 358 | 359 | 360 | ) 361 | ``` 362 | 363 | ### Asynchronous results 364 | 365 | Render the items as they become available. Filtering and sorting will happen automatically. 366 | 367 | ```tsx 368 | const [loading, setLoading] = React.useState(false) 369 | const [items, setItems] = React.useState([]) 370 | 371 | React.useEffect(() => { 372 | async function getItems() { 373 | setLoading(true) 374 | const res = await api.get('/dictionary') 375 | setItems(res) 376 | setLoading(false) 377 | } 378 | 379 | getItems() 380 | }, []) 381 | 382 | return ( 383 | 384 | 385 | 386 | {loading && Fetching words…} 387 | {items.map((item) => { 388 | return ( 389 | 390 | {item} 391 | 392 | ) 393 | })} 394 | 395 | 396 | ) 397 | ``` 398 | 399 | ### Use inside Popover 400 | 401 | We recommend using the [Radix UI popover](https://www.radix-ui.com/docs/primitives/components/popover) component. ⌘K relies on the Radix UI Dialog component, so this will reduce your bundle size a bit due to shared dependencies. 402 | 403 | ```bash 404 | $ pnpm install @radix-ui/react-popover 405 | ``` 406 | 407 | Render `Command` inside of the popover content: 408 | 409 | ```tsx 410 | import * as Popover from '@radix-ui/react-popover' 411 | 412 | return ( 413 | 414 | Toggle popover 415 | 416 | 417 | 418 | 419 | 420 | Apple 421 | 422 | 423 | 424 | 425 | ) 426 | ``` 427 | 428 | ### Drop in stylesheets 429 | 430 | You can find global stylesheets to drop in as a starting point for styling. See [website/styles/cmdk](website/styles/cmdk) for examples. 431 | 432 | ## FAQ 433 | 434 | **Accessible?** Yes. Labeling, aria attributes, and DOM ordering tested with Voice Over and Chrome DevTools. [Dialog](#dialog-cmdk-dialog-cmdk-overlay) composes an accessible Dialog implementation. 435 | 436 | **Virtualization?** No. Good performance up to 2,000-3,000 items, though. Read below to bring your own. 437 | 438 | **Filter/sort items manually?** Yes. Pass `shouldFilter={false}` to [Command](#command-cmdk-root). Better memory usage and performance. Bring your own virtualization this way. 439 | 440 | **React 18 safe?** Yes, required. Uses React 18 hooks like `useId` and `useSyncExternalStore`. 441 | 442 | **Unstyled?** Yes, use the listed CSS selectors. 443 | 444 | **Hydration mismatch?** No, likely a bug in your code. Ensure the `open` prop to `Command.Dialog` is `false` on the server. 445 | 446 | **React strict mode safe?** Yes. Open an issue if you notice an issue. 447 | 448 | **Weird/wrong behavior?** Make sure your `Command.Item` has a `key` and unique `value`. 449 | 450 | **Concurrent mode safe?** Maybe, but concurrent mode is not yet real. Uses risky approaches like manual DOM ordering. 451 | 452 | **React server component?** No, it's a client component. 453 | 454 | **Listen for ⌘K automatically?** No, do it yourself to have full control over keybind context. 455 | 456 | **React Native?** No, and no plans to support it. If you build a React Native version, let us know and we'll link your repository here. 457 | 458 | ## History 459 | 460 | Written in 2019 by Paco ([@pacocoursey](https://twitter.com/pacocoursey)) to see if a composable combobox API was possible. Used for the Vercel command menu and autocomplete by Rauno ([@raunofreiberg](https://twitter.com/raunofreiberg)) in 2020. Re-written independently in 2022 with a simpler and more performant approach. Ideas and help from Shu ([@shuding\_](https://twitter.com/shuding_)). 461 | 462 | [use-descendants](https://github.com/pacocoursey/use-descendants) was extracted from the 2019 version. 463 | 464 | ## Testing 465 | 466 | First, install dependencies and Playwright browsers: 467 | 468 | ```bash 469 | pnpm install 470 | pnpm playwright install 471 | ``` 472 | 473 | Then ensure you've built the library: 474 | 475 | ```bash 476 | pnpm build 477 | ``` 478 | 479 | Then run the tests using your local build against real browser engines: 480 | 481 | ```bash 482 | pnpm test 483 | ``` 484 | -------------------------------------------------------------------------------- /cmdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cmdk", 3 | "version": "1.1.1", 4 | "license": "MIT", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "exports": { 12 | ".": { 13 | "types": "./dist/index.d.ts", 14 | "import": "./dist/index.mjs", 15 | "require": "./dist/index.js" 16 | } 17 | }, 18 | "scripts": { 19 | "prepublishOnly": "cp ../README.md . && pnpm build", 20 | "postpublish": "rm README.md", 21 | "build": "tsup src", 22 | "dev": "tsup src --watch" 23 | }, 24 | "peerDependencies": { 25 | "react": "^18 || ^19 || ^19.0.0-rc", 26 | "react-dom": "^18 || ^19 || ^19.0.0-rc" 27 | }, 28 | "dependencies": { 29 | "@radix-ui/react-compose-refs": "^1.1.1", 30 | "@radix-ui/react-dialog": "^1.1.6", 31 | "@radix-ui/react-id": "^1.1.0", 32 | "@radix-ui/react-primitive": "^2.0.2" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "18.0.15" 36 | }, 37 | "sideEffects": false, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/pacocoursey/cmdk.git", 41 | "directory": "cmdk" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/pacocoursey/cmdk/issues" 45 | }, 46 | "homepage": "https://github.com/pacocoursey/cmdk#readme", 47 | "author": { 48 | "name": "Paco", 49 | "url": "https://github.com/pacocoursey" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cmdk/src/command-score.ts: -------------------------------------------------------------------------------- 1 | // The scores are arranged so that a continuous match of characters will 2 | // result in a total score of 1. 3 | // 4 | // The best case, this character is a match, and either this is the start 5 | // of the string, or the previous character was also a match. 6 | var SCORE_CONTINUE_MATCH = 1, 7 | // A new match at the start of a word scores better than a new match 8 | // elsewhere as it's more likely that the user will type the starts 9 | // of fragments. 10 | // NOTE: We score word jumps between spaces slightly higher than slashes, brackets 11 | // hyphens, etc. 12 | SCORE_SPACE_WORD_JUMP = 0.9, 13 | SCORE_NON_SPACE_WORD_JUMP = 0.8, 14 | // Any other match isn't ideal, but we include it for completeness. 15 | SCORE_CHARACTER_JUMP = 0.17, 16 | // If the user transposed two letters, it should be significantly penalized. 17 | // 18 | // i.e. "ouch" is more likely than "curtain" when "uc" is typed. 19 | SCORE_TRANSPOSITION = 0.1, 20 | // The goodness of a match should decay slightly with each missing 21 | // character. 22 | // 23 | // i.e. "bad" is more likely than "bard" when "bd" is typed. 24 | // 25 | // This will not change the order of suggestions based on SCORE_* until 26 | // 100 characters are inserted between matches. 27 | PENALTY_SKIPPED = 0.999, 28 | // The goodness of an exact-case match should be higher than a 29 | // case-insensitive match by a small amount. 30 | // 31 | // i.e. "HTML" is more likely than "haml" when "HM" is typed. 32 | // 33 | // This will not change the order of suggestions based on SCORE_* until 34 | // 1000 characters are inserted between matches. 35 | PENALTY_CASE_MISMATCH = 0.9999, 36 | // Match higher for letters closer to the beginning of the word 37 | PENALTY_DISTANCE_FROM_START = 0.9, 38 | // If the word has more characters than the user typed, it should 39 | // be penalised slightly. 40 | // 41 | // i.e. "html" is more likely than "html5" if I type "html". 42 | // 43 | // However, it may well be the case that there's a sensible secondary 44 | // ordering (like alphabetical) that it makes sense to rely on when 45 | // there are many prefix matches, so we don't make the penalty increase 46 | // with the number of tokens. 47 | PENALTY_NOT_COMPLETE = 0.99 48 | 49 | var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/, 50 | COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g, 51 | IS_SPACE_REGEXP = /[\s-]/, 52 | COUNT_SPACE_REGEXP = /[\s-]/g 53 | 54 | function commandScoreInner( 55 | string, 56 | abbreviation, 57 | lowerString, 58 | lowerAbbreviation, 59 | stringIndex, 60 | abbreviationIndex, 61 | memoizedResults, 62 | ) { 63 | if (abbreviationIndex === abbreviation.length) { 64 | if (stringIndex === string.length) { 65 | return SCORE_CONTINUE_MATCH 66 | } 67 | return PENALTY_NOT_COMPLETE 68 | } 69 | 70 | var memoizeKey = `${stringIndex},${abbreviationIndex}` 71 | if (memoizedResults[memoizeKey] !== undefined) { 72 | return memoizedResults[memoizeKey] 73 | } 74 | 75 | var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex) 76 | var index = lowerString.indexOf(abbreviationChar, stringIndex) 77 | var highScore = 0 78 | 79 | var score, transposedScore, wordBreaks, spaceBreaks 80 | 81 | while (index >= 0) { 82 | score = commandScoreInner( 83 | string, 84 | abbreviation, 85 | lowerString, 86 | lowerAbbreviation, 87 | index + 1, 88 | abbreviationIndex + 1, 89 | memoizedResults, 90 | ) 91 | if (score > highScore) { 92 | if (index === stringIndex) { 93 | score *= SCORE_CONTINUE_MATCH 94 | } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { 95 | score *= SCORE_NON_SPACE_WORD_JUMP 96 | wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP) 97 | if (wordBreaks && stringIndex > 0) { 98 | score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length) 99 | } 100 | } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { 101 | score *= SCORE_SPACE_WORD_JUMP 102 | spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP) 103 | if (spaceBreaks && stringIndex > 0) { 104 | score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length) 105 | } 106 | } else { 107 | score *= SCORE_CHARACTER_JUMP 108 | if (stringIndex > 0) { 109 | score *= Math.pow(PENALTY_SKIPPED, index - stringIndex) 110 | } 111 | } 112 | 113 | if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { 114 | score *= PENALTY_CASE_MISMATCH 115 | } 116 | } 117 | 118 | if ( 119 | (score < SCORE_TRANSPOSITION && 120 | lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) || 121 | (lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 122 | lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) 123 | ) { 124 | transposedScore = commandScoreInner( 125 | string, 126 | abbreviation, 127 | lowerString, 128 | lowerAbbreviation, 129 | index + 1, 130 | abbreviationIndex + 2, 131 | memoizedResults, 132 | ) 133 | 134 | if (transposedScore * SCORE_TRANSPOSITION > score) { 135 | score = transposedScore * SCORE_TRANSPOSITION 136 | } 137 | } 138 | 139 | if (score > highScore) { 140 | highScore = score 141 | } 142 | 143 | index = lowerString.indexOf(abbreviationChar, index + 1) 144 | } 145 | 146 | memoizedResults[memoizeKey] = highScore 147 | return highScore 148 | } 149 | 150 | function formatInput(string) { 151 | // convert all valid space characters to space so they match each other 152 | return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ') 153 | } 154 | 155 | export function commandScore(string: string, abbreviation: string, aliases: string[]): number { 156 | /* NOTE: 157 | * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() 158 | * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. 159 | */ 160 | string = aliases && aliases.length > 0 ? `${string + ' ' + aliases.join(' ')}` : string 161 | return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {}) 162 | } 163 | -------------------------------------------------------------------------------- /cmdk/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | sourcemap: false, 5 | minify: true, 6 | dts: true, 7 | format: ['esm', 'cjs'], 8 | loader: { 9 | '.js': 'jsx', 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cmdk-root", 3 | "private": true, 4 | "scripts": { 5 | "build": "pnpm -F cmdk build", 6 | "dev": "pnpm -F cmdk build --watch", 7 | "website": "pnpm -F cmdk-website dev", 8 | "testsite": "pnpm -F cmdk-tests dev", 9 | "format": "prettier '**/*.{js,jsx,ts,tsx,json,md,mdx,css,scss,yaml,yml}' --write", 10 | "preinstall": "npx only-allow pnpm", 11 | "test:format": "prettier '**/*.{js,jsx,ts,tsx,json,md,mdx,css,scss,yaml,yml}' --check", 12 | "test": "playwright test" 13 | }, 14 | "devDependencies": { 15 | "@playwright/test": "1.51.0", 16 | "husky": "^8.0.1", 17 | "lint-staged": "15.2.0", 18 | "prettier": "2.7.1", 19 | "tsup": "8.0.1", 20 | "typescript": "4.6.4" 21 | }, 22 | "packageManager": "pnpm@8.8.0", 23 | "lint-staged": { 24 | "**/*.{js,jsx,ts,tsx,json,md,mdx,css,scss,yaml,yml}": [ 25 | "prettier --write" 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from '@playwright/test' 2 | 3 | const config: PlaywrightTestConfig = { 4 | forbidOnly: !!process.env.CI, 5 | retries: process.env.CI ? 2 : 0, 6 | reporter: process.env.CI ? [['github'], ['json', { outputFile: 'playwright-report.json' }]] : 'list', 7 | testDir: './test', 8 | use: { 9 | trace: 'on-first-retry', 10 | baseURL: 'http://localhost:3000', 11 | }, 12 | timeout: 5000, 13 | webServer: { 14 | command: 'npm run dev', 15 | url: 'http://localhost:3000', 16 | cwd: './test', 17 | reuseExistingServer: !process.env.CI, 18 | }, 19 | projects: [ 20 | { 21 | name: 'webkit', 22 | use: { ...devices['Desktop Safari'], headless: true }, 23 | }, 24 | ], 25 | } 26 | 27 | export default config 28 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'website' 3 | - 'test' 4 | - 'cmdk' 5 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('basic behavior', async () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/') 6 | }) 7 | 8 | test('input props are forwarded', async ({ page }) => { 9 | const input = page.locator(`input[placeholder="Search…"]`) 10 | await expect(input).toHaveCount(1) 11 | }) 12 | 13 | test('item value is derived from textContent', async ({ page }) => { 14 | const item = page.locator(`[cmdk-item][data-value="Item"]`) 15 | await expect(item).toHaveText('Item') 16 | }) 17 | 18 | test('item value prop is preferred over textContent', async ({ page }) => { 19 | const item = page.locator(`[cmdk-item][data-value="xxx"]`) 20 | await expect(item).toHaveText('Value') 21 | }) 22 | 23 | test('item onSelect is called on click', async ({ page }) => { 24 | const item = page.locator(`[cmdk-item][data-value="Item"]`) 25 | await item.click() 26 | expect(await page.evaluate(() => (window as any).onSelect)).toEqual('Item selected') 27 | }) 28 | 29 | test('first item is selected by default', async ({ page }) => { 30 | const item = page.locator(`[cmdk-item][aria-selected="true"]`) 31 | await expect(item).toHaveText('Item') 32 | }) 33 | 34 | test('first item is selected when search changes', async ({ page }) => { 35 | const input = page.locator(`[cmdk-input]`) 36 | await input.type('x') 37 | const selected = page.locator(`[cmdk-item][aria-selected="true"]`) 38 | await expect(selected).toHaveText('Value') 39 | }) 40 | 41 | test('items filter when searching', async ({ page }) => { 42 | const input = page.locator(`[cmdk-input]`) 43 | await input.type('x') 44 | const removed = page.locator(`[cmdk-item][data-value="Item"]`) 45 | const remains = page.locator(`[cmdk-item][data-value="xxx"]`) 46 | await expect(removed).toHaveCount(0) 47 | await expect(remains).toHaveCount(1) 48 | }) 49 | 50 | test('items filter when searching by keywords', async ({ page }) => { 51 | const input = page.locator(`[cmdk-input]`) 52 | await input.type('key') 53 | const removed = page.locator(`[cmdk-item][data-value="xxx"]`) 54 | const remains = page.locator(`[cmdk-item][data-value="Item"]`) 55 | await expect(removed).toHaveCount(0) 56 | await expect(remains).toHaveCount(1) 57 | }) 58 | 59 | test('empty component renders when there are no results', async ({ page }) => { 60 | const input = page.locator('[cmdk-input]') 61 | await input.type('z') 62 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) 63 | await expect(page.locator(`[cmdk-empty]`)).toHaveText('No results.') 64 | }) 65 | 66 | test('className is applied to each part', async ({ page }) => { 67 | await expect(page.locator(`.root`)).toHaveCount(1) 68 | await expect(page.locator(`.input`)).toHaveCount(1) 69 | await expect(page.locator(`.list`)).toHaveCount(1) 70 | await expect(page.locator(`.item`)).toHaveCount(2) 71 | await page.locator('[cmdk-input]').type('zzzz') 72 | await expect(page.locator(`.item`)).toHaveCount(0) 73 | await expect(page.locator(`.empty`)).toHaveCount(1) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/dialog.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('dialog', async () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/dialog') 6 | }) 7 | 8 | test('dialog renders in portal', async ({ page }) => { 9 | await expect(page.locator(`[cmdk-dialog]`)).toHaveCount(1) 10 | await expect(page.locator(`[cmdk-overlay]`)).toHaveCount(1) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /test/group.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('group', async () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/group') 6 | }) 7 | 8 | test('groups are shown/hidden based on item matches', async ({ page }) => { 9 | await page.locator(`[cmdk-input]`).type('z') 10 | await expect(page.locator(`[cmdk-group][data-value="Animals"]`)).not.toBeVisible() 11 | await expect(page.locator(`[cmdk-group][data-value="Letters"]`)).toBeVisible() 12 | }) 13 | 14 | test('group can be progressively rendered', async ({ page }) => { 15 | await expect(page.locator(`[cmdk-group][data-value="Numbers"]`)).not.toBeVisible() 16 | await page.locator(`[cmdk-input]`).type('t') 17 | await expect(page.locator(`[cmdk-group][data-value="Animals"]`)).not.toBeVisible() 18 | await expect(page.locator(`[cmdk-group][data-value="Letters"]`)).not.toBeVisible() 19 | await expect(page.locator(`[cmdk-group][data-value="Numbers"]`)).toBeVisible() 20 | }) 21 | 22 | test('mounted group still rendered with filter using forceMount', async ({ page }) => { 23 | await page.locator(`data-testid=forceMount`).click() 24 | await page.locator(`[cmdk-input]`).type('Giraffe') 25 | await expect(page.locator(`[cmdk-group][data-value="Letters"]`)).toBeVisible() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/item.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('item', async () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/item') 6 | }) 7 | 8 | test('mounted item matches search', async ({ page }) => { 9 | await page.locator(`[cmdk-input]`).type('b') 10 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) 11 | await page.locator(`data-testid=mount`).click() 12 | await expect(page.locator(`[cmdk-item]`)).toHaveText('B') 13 | }) 14 | 15 | test('mounted item does not match search', async ({ page }) => { 16 | await page.locator(`[cmdk-input]`).type('z') 17 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) 18 | await page.locator(`data-testid=mount`).click() 19 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) 20 | }) 21 | 22 | test('unmount item that is selected', async ({ page }) => { 23 | await page.locator(`data-testid=mount`).click() 24 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('A') 25 | await page.locator(`data-testid=unmount`).click() 26 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(1) 27 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('B') 28 | }) 29 | 30 | test('unmount item that is the only result', async ({ page }) => { 31 | await page.locator(`data-testid=unmount`).click() 32 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) 33 | }) 34 | 35 | test('mount item that is the only result', async ({ page }) => { 36 | await page.locator(`data-testid=unmount`).click() 37 | await expect(page.locator(`[cmdk-empty]`)).toHaveCount(1) 38 | await page.locator(`data-testid=mount`).click() 39 | await expect(page.locator(`[cmdk-empty]`)).toHaveCount(0) 40 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(1) 41 | }) 42 | 43 | test('selected does not change when mounting new items', async ({ page }) => { 44 | await page.locator(`data-testid=mount`).click() 45 | await page.locator(`[cmdk-item][data-value="B"]`).click() 46 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('B') 47 | await page.locator(`data-testid=many`).click() 48 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveText('B') 49 | }) 50 | 51 | test('mounted item still rendered with filter usingForceMount', async ({ page }) => { 52 | await page.locator(`data-testid=forceMount`).click() 53 | await page.locator(`[cmdk-input]`).type('z') 54 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(1) 55 | }) 56 | }) 57 | 58 | test.describe('item advanced', async () => { 59 | test.beforeEach(async ({ page }) => { 60 | await page.goto('/item-advanced') 61 | }) 62 | 63 | test('re-rendering re-matches implicit textContent value', async ({ page }) => { 64 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) 65 | await page.locator(`[cmdk-input]`).type('2') 66 | const button = page.locator(`data-testid=increment`) 67 | await button.click() 68 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(0) 69 | await button.click() 70 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /test/keybind.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('arrow keybinds', async () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/keybinds') 6 | }) 7 | 8 | test('arrow up/down changes selected item', async ({ page }) => { 9 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 10 | await page.locator(`[cmdk-input]`).press('ArrowDown') 11 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 12 | await page.locator(`[cmdk-input]`).press('ArrowUp') 13 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 14 | }) 15 | 16 | test('meta arrow up/down goes to first and last item', async ({ page }) => { 17 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 18 | await page.locator(`[cmdk-input]`).press('Meta+ArrowDown') 19 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last') 20 | await page.locator(`[cmdk-input]`).press('Meta+ArrowUp') 21 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 22 | }) 23 | 24 | test('alt arrow up/down goes to next and prev item', async ({ page }) => { 25 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 26 | await page.locator(`[cmdk-input]`).press('Alt+ArrowDown') 27 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 28 | await page.locator(`[cmdk-input]`).press('Alt+ArrowDown') 29 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Apple') 30 | await page.locator(`[cmdk-input]`).press('Alt+ArrowUp') 31 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 32 | await page.locator(`[cmdk-input]`).press('Alt+ArrowUp') 33 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 34 | }) 35 | }) 36 | 37 | test.describe('vim jk keybinds', async () => { 38 | test.beforeEach(async ({ page }) => { 39 | await page.goto('/keybinds') 40 | }) 41 | 42 | test('ctrl j/k changes selected item', async ({ page }) => { 43 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 44 | await page.locator(`[cmdk-input]`).press('Control+j') 45 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 46 | await page.locator(`[cmdk-input]`).press('Control+k') 47 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 48 | }) 49 | 50 | test('meta ctrl j/k goes to first and last item', async ({ page }) => { 51 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 52 | await page.locator(`[cmdk-input]`).press('Meta+Control+j') 53 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last') 54 | await page.locator(`[cmdk-input]`).press('Meta+Control+k') 55 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 56 | }) 57 | 58 | test('alt ctrl j/k goes to next and prev item', async ({ page }) => { 59 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 60 | await page.locator(`[cmdk-input]`).press('Alt+Control+j') 61 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 62 | await page.locator(`[cmdk-input]`).press('Alt+Control+j') 63 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Apple') 64 | await page.locator(`[cmdk-input]`).press('Alt+Control+k') 65 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 66 | await page.locator(`[cmdk-input]`).press('Alt+Control+k') 67 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 68 | }) 69 | }) 70 | 71 | test.describe('vim np keybinds', async () => { 72 | test.beforeEach(async ({ page }) => { 73 | await page.goto('/keybinds') 74 | }) 75 | 76 | test('ctrl n/p changes selected item', async ({ page }) => { 77 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 78 | await page.locator(`[cmdk-input]`).press('Control+n') 79 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 80 | await page.locator(`[cmdk-input]`).press('Control+p') 81 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 82 | }) 83 | 84 | test('meta ctrl n/p goes to first and last item', async ({ page }) => { 85 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 86 | await page.locator(`[cmdk-input]`).press('Meta+Control+n') 87 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last') 88 | await page.locator(`[cmdk-input]`).press('Meta+Control+p') 89 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 90 | }) 91 | 92 | test('alt ctrl n/p goes to next and prev item', async ({ page }) => { 93 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 94 | await page.locator(`[cmdk-input]`).press('Alt+Control+n') 95 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 96 | await page.locator(`[cmdk-input]`).press('Alt+Control+n') 97 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Apple') 98 | await page.locator(`[cmdk-input]`).press('Alt+Control+p') 99 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'A') 100 | await page.locator(`[cmdk-input]`).press('Alt+Control+p') 101 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 102 | }) 103 | }) 104 | 105 | test.describe('no-vim keybinds', async () => { 106 | test.beforeEach(async ({ page }) => { 107 | await page.goto('/keybinds?noVim=true') 108 | }) 109 | 110 | test('ctrl j/k does nothing', async ({ page }) => { 111 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 112 | await page.locator(`[cmdk-input]`).press('Control+j') 113 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 114 | await page.locator(`[cmdk-input]`).press('Control+k') 115 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 116 | }) 117 | 118 | test('ctrl n/p does nothing', async ({ page }) => { 119 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 120 | await page.locator(`[cmdk-input]`).press('Control+n') 121 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 122 | await page.locator(`[cmdk-input]`).press('Control+p') 123 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first') 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /test/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /test/numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('behavior for numeric values', async () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/numeric') 6 | }) 7 | 8 | test('items filter correctly on numeric inputs', async ({ page }) => { 9 | const input = page.locator(`[cmdk-input]`) 10 | await input.type('112') 11 | const removed = page.locator(`[cmdk-item][data-value="removed"]`) 12 | const remains = page.locator(`[cmdk-item][data-value="foo.bar112.value"]`) 13 | await expect(removed).toHaveCount(0) 14 | await expect(remains).toHaveCount(1) 15 | }) 16 | 17 | test('items filter correctly on non-numeric inputs', async ({ page }) => { 18 | const input = page.locator(`[cmdk-input]`) 19 | await input.type('bar') 20 | const removed = page.locator(`[cmdk-item][data-value="removed"]`) 21 | const remains = page.locator(`[cmdk-item][data-value="foo.bar112.value"]`) 22 | await expect(removed).toHaveCount(0) 23 | await expect(remains).toHaveCount(1) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cmdk-tests", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "next" 6 | }, 7 | "dependencies": { 8 | "@radix-ui/react-portal": "^1.0.4", 9 | "@types/node": "18.0.4", 10 | "@types/react": "18.0.15", 11 | "@types/react-dom": "18.0.6", 12 | "cmdk": "workspace:*", 13 | "next": "13.5.1", 14 | "react": "18.2.0", 15 | "react-dom": "18.2.0", 16 | "typescript": "4.7.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../style.css' 2 | 3 | export default function App({ Component, pageProps }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /test/pages/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import * as React from 'react' 3 | 4 | const Page = () => { 5 | const [open, setOpen] = React.useState(false) 6 | 7 | React.useEffect(() => { 8 | setOpen(true) 9 | }, []) 10 | 11 | return ( 12 |
13 | 14 | 15 | 16 | No results. 17 | console.log('Item selected')}>Item 18 | Value 19 | 20 | 21 |
22 | ) 23 | } 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /test/pages/group.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import * as React from 'react' 3 | 4 | const Page = () => { 5 | const [search, setSearch] = React.useState('') 6 | const [forceMount, setForceMount] = React.useState(false) 7 | 8 | return ( 9 |
10 | 13 | 14 | 15 | 16 | 17 | No results. 18 | 19 | Giraffe 20 | Chicken 21 | 22 | 23 | 24 | A 25 | B 26 | Z 27 | 28 | 29 | {!!search && ( 30 | 31 | One 32 | Two 33 | Three 34 | 35 | )} 36 | 37 | 38 |
39 | ) 40 | } 41 | 42 | export default Page 43 | -------------------------------------------------------------------------------- /test/pages/huge.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import * as React from 'react' 3 | 4 | const items = new Array(1000).fill(0) 5 | 6 | const Page = () => { 7 | return ( 8 |
9 | { 12 | console.log({ phase, actualDuration, baseDuration }) 13 | }} 14 | > 15 | 16 | 17 | 18 | {items.map((_, i) => { 19 | return 20 | })} 21 | 22 | 23 | 24 |
25 | ) 26 | } 27 | 28 | const Item = () => { 29 | const id = React.useId() 30 | 31 | return Item {id} 32 | } 33 | 34 | export default Page 35 | -------------------------------------------------------------------------------- /test/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | 3 | const Page = () => { 4 | return ( 5 |
6 | 7 | 8 | 9 | No results. 10 | { 13 | ;(window as any).onSelect = 'Item selected' 14 | }} 15 | className="item" 16 | > 17 | Item 18 | 19 | 20 | Value 21 | 22 | 23 | 24 |
25 | ) 26 | } 27 | 28 | export default Page 29 | -------------------------------------------------------------------------------- /test/pages/item-advanced.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import * as React from 'react' 3 | 4 | const Page = () => { 5 | const [count, setCount] = React.useState(0) 6 | 7 | return ( 8 |
9 | 12 | 13 | 14 | 15 | 16 | No results. 17 | Item A {count} 18 | Item B {count} 19 | 20 | 21 |
22 | ) 23 | } 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /test/pages/item.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import * as React from 'react' 3 | 4 | const Page = () => { 5 | const [unmount, setUnmount] = React.useState(false) 6 | const [mount, setMount] = React.useState(false) 7 | const [many, setMany] = React.useState(false) 8 | const [forceMount, setForceMount] = React.useState(false) 9 | 10 | return ( 11 |
12 | 15 | 16 | 19 | 20 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | No results. 32 | {!unmount && A} 33 | {many && ( 34 | <> 35 | 1 36 | 2 37 | 3 38 | 39 | )} 40 | {mount && B} 41 | 42 | 43 |
44 | ) 45 | } 46 | 47 | export default Page 48 | -------------------------------------------------------------------------------- /test/pages/keybinds.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import { useRouter } from 'next/router' 3 | import * as React from 'react' 4 | 5 | const Page = () => { 6 | const { 7 | query: { noVim }, 8 | } = useRouter() 9 | return ( 10 |
11 | 12 | 13 | 14 | No results. 15 | 16 | 17 | Disabled 18 | 19 | 20 | First 21 | 22 | 23 | A 24 | B 25 | Z 26 | 27 | 28 | 29 | Apple 30 | Banana 31 | Orange 32 | Dragon Fruit 33 | Pear 34 | 35 | 36 | Last 37 | 38 | 39 | Disabled 3 40 | 41 | 42 | 43 |
44 | ) 45 | } 46 | 47 | export default Page 48 | -------------------------------------------------------------------------------- /test/pages/numeric.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | 3 | const Page = () => { 4 | return ( 5 |
6 | 7 | 8 | 9 | No results. 10 | 11 | To be removed 12 | 13 | 14 | Not to be removed 15 | 16 | 17 | 18 |
19 | ) 20 | } 21 | 22 | export default Page 23 | -------------------------------------------------------------------------------- /test/pages/portal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Command } from 'cmdk' 3 | import * as Portal from '@radix-ui/react-portal' 4 | 5 | const Page = () => { 6 | const [render, setRender] = React.useState(false) 7 | const [search, setSearch] = React.useState('') 8 | const [open, setOpen] = React.useState(true) 9 | React.useEffect(() => setRender(true), []) 10 | if (!render) return null 11 | 12 | return ( 13 |
14 | 17 | 20 | 21 | 22 | 23 | 24 | {open && ( 25 | 26 | Apple 27 | Banana 28 | Cherry 29 | Dragonfruit 30 | Elderberry 31 | Fig 32 | Grape 33 | Honeydew 34 | Jackfruit 35 | Kiwi 36 | Lemon 37 | Mango 38 | Nectarine 39 | Orange 40 | Papaya 41 | Quince 42 | Raspberry 43 | Strawberry 44 | Tangerine 45 | Ugli 46 | Watermelon 47 | Xigua 48 | Yuzu 49 | Zucchini 50 | 51 | )} 52 | 53 | 54 |
55 | ) 56 | } 57 | 58 | export default Page 59 | -------------------------------------------------------------------------------- /test/pages/props.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import { useRouter } from 'next/router' 3 | import * as React from 'react' 4 | 5 | const Page = () => { 6 | const [value, setValue] = React.useState('ant') 7 | const [search, setSearch] = React.useState('') 8 | const [shouldFilter, setShouldFilter] = React.useState(true) 9 | const [customFilter, setCustomFilter] = React.useState(false) 10 | const router = useRouter() 11 | 12 | React.useEffect(() => { 13 | if (router.isReady) { 14 | setShouldFilter(router.query.shouldFilter === 'false' ? false : true) 15 | setCustomFilter(router.query.customFilter === 'true' ? true : false) 16 | setValue((router.query.initialValue as string) ?? 'ant') 17 | } 18 | }, [router.isReady]) 19 | 20 | return ( 21 |
22 |
{value}
23 |
{search}
24 | 25 | 28 | 31 | 32 | { 39 | console.log(item, search) 40 | if (!search || !item) return 1 41 | return item.endsWith(search) ? 1 : 0 42 | } 43 | : undefined 44 | } 45 | > 46 | 47 | 48 | ant 49 | anteater 50 | 51 | 52 |
53 | ) 54 | } 55 | 56 | export default Page 57 | -------------------------------------------------------------------------------- /test/props.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('props', async () => { 4 | test('results do not change when filtering is disabled', async ({ page }) => { 5 | await page.goto('/props?shouldFilter=false') 6 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) 7 | await page.locator(`[cmdk-input]`).type('z') 8 | await expect(page.locator(`[cmdk-item]`)).toHaveCount(2) 9 | }) 10 | 11 | test('results match against custom filter', async ({ page }) => { 12 | await page.goto('/props?customFilter=true') 13 | await page.locator(`[cmdk-input]`).type(`ant`) 14 | await expect(page.locator(`[cmdk-item]`)).toHaveAttribute('data-value', 'ant') 15 | }) 16 | 17 | test('controlled value', async ({ page }) => { 18 | await page.goto('/props') 19 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'ant') 20 | await page.locator(`data-testid=controlledValue`).click() 21 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'anteater') 22 | }) 23 | 24 | test('keep controlled value if empty results', async ({ page }) => { 25 | await page.goto('/props') 26 | await expect(page.locator(`[data-testid=value]`)).toHaveText('ant') 27 | await page.locator(`[cmdk-input]`).fill('d') 28 | await expect(page.locator(`[data-testid=value]`)).toHaveText('') 29 | await page.locator(`[cmdk-input]`).fill('ant') 30 | await expect(page.locator(`[data-testid=value]`)).toHaveText('ant') 31 | }) 32 | 33 | test('controlled search', async ({ page }) => { 34 | await page.goto('/props') 35 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'ant') 36 | await page.locator(`data-testid=controlledSearch`).click() 37 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'anteater') 38 | }) 39 | 40 | test('keep focus on the provided initial value', async ({ page }) => { 41 | await page.goto('/props?initialValue=anteater') 42 | await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'anteater') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/style.css: -------------------------------------------------------------------------------- 1 | [cmdk-item][aria-selected='true'] { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../dialog.test.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react", 15 | "noEmit": true 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 18 | "exclude": ["node_modules", "build", "dist", ".next"] 19 | } 20 | -------------------------------------------------------------------------------- /website/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | pnpm run dev 9 | ``` 10 | 11 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 12 | 13 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 14 | 15 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 16 | 17 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 18 | 19 | ## Learn More 20 | 21 | To learn more about Next.js, take a look at the following resources: 22 | 23 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 24 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 25 | 26 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 27 | 28 | ## Deploy on Vercel 29 | 30 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 31 | 32 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 33 | -------------------------------------------------------------------------------- /website/components/cmdk/framer.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | import React from 'react' 3 | 4 | export function FramerCMDK() { 5 | const [value, setValue] = React.useState('Button') 6 | return ( 7 |
8 | setValue(v)}> 9 |
10 | 11 | 12 |
13 | 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 |
42 | {value === 'Button' &&
50 |
51 |
52 |
53 |
54 | ) 55 | } 56 | 57 | function Button() { 58 | return 59 | } 60 | 61 | function Input() { 62 | return 63 | } 64 | 65 | function Badge() { 66 | return
Badge
67 | } 68 | 69 | function Radio() { 70 | return ( 71 | 75 | ) 76 | } 77 | 78 | function Slider() { 79 | return ( 80 |
81 |
82 |
83 | ) 84 | } 85 | 86 | function Avatar() { 87 | return Avatar of Rauno 88 | } 89 | 90 | function Container() { 91 | return
92 | } 93 | 94 | function Item({ children, value, subtitle }: { children: React.ReactNode; value: string; subtitle: string }) { 95 | return ( 96 | {}}> 97 |
{children}
98 |
99 | {value} 100 | {subtitle} 101 |
102 |
103 | ) 104 | } 105 | 106 | function ButtonIcon() { 107 | return ( 108 | 109 | 115 | 116 | ) 117 | } 118 | 119 | function InputIcon() { 120 | return ( 121 | 122 | 128 | 129 | ) 130 | } 131 | 132 | function RadioIcon() { 133 | return ( 134 | 135 | 141 | 142 | ) 143 | } 144 | 145 | function BadgeIcon() { 146 | return ( 147 | 148 | 154 | 155 | ) 156 | } 157 | 158 | function ToggleIcon() { 159 | return ( 160 | 161 | 167 | 168 | ) 169 | } 170 | 171 | function AvatarIcon() { 172 | return ( 173 | 174 | 180 | 181 | ) 182 | } 183 | 184 | function ContainerIcon() { 185 | return ( 186 | 187 | 193 | 194 | ) 195 | } 196 | 197 | function SearchIcon() { 198 | return ( 199 | 207 | 208 | 209 | ) 210 | } 211 | 212 | function SliderIcon() { 213 | return ( 214 | 215 | 221 | 222 | ) 223 | } 224 | -------------------------------------------------------------------------------- /website/components/cmdk/linear.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk' 2 | 3 | export function LinearCMDK() { 4 | return ( 5 |
6 | 7 |
Issue - FUN-343
8 | 9 | 10 | No results found. 11 | {items.map(({ icon, label, shortcut }) => { 12 | return ( 13 | 14 | {icon} 15 | {label} 16 |
17 | {shortcut.map((key) => { 18 | return {key} 19 | })} 20 |
21 |
22 | ) 23 | })} 24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | const items = [ 31 | { 32 | icon: , 33 | label: 'Assign to...', 34 | shortcut: ['A'], 35 | }, 36 | { 37 | icon: , 38 | label: 'Assign to me', 39 | shortcut: ['I'], 40 | }, 41 | { 42 | icon: , 43 | label: 'Change status...', 44 | shortcut: ['S'], 45 | }, 46 | { 47 | icon: , 48 | label: 'Change priority...', 49 | shortcut: ['P'], 50 | }, 51 | { 52 | icon: , 53 | label: 'Change labels...', 54 | shortcut: ['L'], 55 | }, 56 | { 57 | icon: , 58 | label: 'Remove label...', 59 | shortcut: ['⇧', 'L'], 60 | }, 61 | { 62 | icon: , 63 | label: 'Set due date...', 64 | shortcut: ['⇧', 'D'], 65 | }, 66 | ] 67 | 68 | function AssignToIcon() { 69 | return ( 70 | 71 | 72 | 73 | ) 74 | } 75 | 76 | function AssignToMeIcon() { 77 | return ( 78 | 79 | 80 | 85 | 86 | 87 | ) 88 | } 89 | 90 | function ChangeStatusIcon() { 91 | return ( 92 | 93 | 94 | 99 | 100 | ) 101 | } 102 | 103 | function ChangePriorityIcon() { 104 | return ( 105 | 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | 113 | function ChangeLabelsIcon() { 114 | return ( 115 | 116 | 121 | 122 | ) 123 | } 124 | 125 | function RemoveLabelIcon() { 126 | return ( 127 | 128 | 133 | 134 | ) 135 | } 136 | 137 | function SetDueDateIcon() { 138 | return ( 139 | 140 | 145 | 146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /website/components/cmdk/raycast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTheme } from 'next-themes' 3 | import * as Popover from '@radix-ui/react-popover' 4 | import { Command } from 'cmdk' 5 | import { Logo, LinearIcon, FigmaIcon, SlackIcon, YouTubeIcon, RaycastIcon } from 'components' 6 | 7 | export function RaycastCMDK() { 8 | const { resolvedTheme: theme } = useTheme() 9 | const [value, setValue] = React.useState('linear') 10 | const inputRef = React.useRef(null) 11 | const listRef = React.useRef(null) 12 | 13 | React.useEffect(() => { 14 | inputRef?.current?.focus() 15 | }, []) 16 | 17 | return ( 18 |
19 | setValue(v)}> 20 |
21 | 22 |
23 | 24 | No results found. 25 | 26 | 27 | 28 | 34 | 35 | Linear 36 | 37 | 38 | 39 | 40 | 41 | Figma 42 | 43 | 44 | 45 | 46 | 47 | Slack 48 | 49 | 50 | 51 | 52 | 53 | YouTube 54 | 55 | 56 | 57 | 58 | 59 | Raycast 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Clipboard History 68 | 69 | 70 | 71 | Import Extension 72 | 73 | 74 | 75 | Manage Extensions 76 | 77 | 78 | 79 | 80 |
81 | {theme === 'dark' ? : } 82 | 83 | 87 | 88 |
89 | 90 | 91 |
92 | 93 |
94 | ) 95 | } 96 | 97 | function Item({ 98 | children, 99 | value, 100 | keywords, 101 | isCommand = false, 102 | }: { 103 | children: React.ReactNode 104 | value: string 105 | keywords?: string[] 106 | isCommand?: boolean 107 | }) { 108 | return ( 109 | {}}> 110 | {children} 111 | {isCommand ? 'Command' : 'Application'} 112 | 113 | ) 114 | } 115 | 116 | function SubCommand({ 117 | inputRef, 118 | listRef, 119 | selectedValue, 120 | }: { 121 | inputRef: React.RefObject 122 | listRef: React.RefObject 123 | selectedValue: string 124 | }) { 125 | const [open, setOpen] = React.useState(false) 126 | 127 | React.useEffect(() => { 128 | function listener(e: KeyboardEvent) { 129 | if (e.key === 'k' && e.metaKey) { 130 | e.preventDefault() 131 | setOpen((o) => !o) 132 | } 133 | } 134 | 135 | document.addEventListener('keydown', listener) 136 | 137 | return () => { 138 | document.removeEventListener('keydown', listener) 139 | } 140 | }, []) 141 | 142 | React.useEffect(() => { 143 | const el = listRef.current 144 | 145 | if (!el) return 146 | 147 | if (open) { 148 | el.style.overflow = 'hidden' 149 | } else { 150 | el.style.overflow = '' 151 | } 152 | }, [open, listRef]) 153 | 154 | return ( 155 | 156 | setOpen(true)} aria-expanded={open}> 157 | Actions 158 | 159 | K 160 | 161 | { 168 | e.preventDefault() 169 | inputRef?.current?.focus() 170 | }} 171 | > 172 | 173 | 174 | 175 | 176 | 177 | Open Application 178 | 179 | 180 | 181 | Show in Finder 182 | 183 | 184 | 185 | Show Info in Finder 186 | 187 | 188 | 189 | Add to Favorites 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | ) 198 | } 199 | 200 | function SubItem({ children, shortcut }: { children: React.ReactNode; shortcut: string }) { 201 | return ( 202 | 203 | {children} 204 |
205 | {shortcut.split(' ').map((key) => { 206 | return {key} 207 | })} 208 |
209 |
210 | ) 211 | } 212 | 213 | function TerminalIcon() { 214 | return ( 215 | 225 | 226 | 227 | 228 | ) 229 | } 230 | 231 | function RaycastLightIcon() { 232 | return ( 233 | 234 | 240 | 241 | ) 242 | } 243 | 244 | function RaycastDarkIcon() { 245 | return ( 246 | 247 | 253 | 254 | ) 255 | } 256 | 257 | function WindowIcon() { 258 | return ( 259 | 260 | 267 | 268 | ) 269 | } 270 | 271 | function FinderIcon() { 272 | return ( 273 | 274 | 281 | 282 | ) 283 | } 284 | 285 | function StarIcon() { 286 | return ( 287 | 288 | 295 | 296 | ) 297 | } 298 | 299 | function ClipboardIcon() { 300 | return ( 301 |
302 | 303 | 310 | 311 |
312 | ) 313 | } 314 | 315 | function HammerIcon() { 316 | return ( 317 |
318 | 319 | 326 | 327 |
328 | ) 329 | } 330 | -------------------------------------------------------------------------------- /website/components/cmdk/vercel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Command } from 'cmdk' 3 | 4 | export function VercelCMDK() { 5 | const ref = React.useRef(null) 6 | const [inputValue, setInputValue] = React.useState('') 7 | 8 | const [pages, setPages] = React.useState(['home']) 9 | const activePage = pages[pages.length - 1] 10 | const isHome = activePage === 'home' 11 | 12 | const popPage = React.useCallback(() => { 13 | setPages((pages) => { 14 | const x = [...pages] 15 | x.splice(-1, 1) 16 | return x 17 | }) 18 | }, []) 19 | 20 | const onKeyDown = React.useCallback( 21 | (e: KeyboardEvent) => { 22 | if (isHome || inputValue.length) { 23 | return 24 | } 25 | 26 | if (e.key === 'Backspace') { 27 | e.preventDefault() 28 | popPage() 29 | } 30 | }, 31 | [inputValue.length, isHome, popPage], 32 | ) 33 | 34 | function bounce() { 35 | if (ref.current) { 36 | ref.current.style.transform = 'scale(0.96)' 37 | setTimeout(() => { 38 | if (ref.current) { 39 | ref.current.style.transform = '' 40 | } 41 | }, 100) 42 | 43 | setInputValue('') 44 | } 45 | } 46 | 47 | return ( 48 |
49 | { 52 | if (e.key === 'Enter') { 53 | bounce() 54 | } 55 | 56 | if (isHome || inputValue.length) { 57 | return 58 | } 59 | 60 | if (e.key === 'Backspace') { 61 | e.preventDefault() 62 | popPage() 63 | bounce() 64 | } 65 | }} 66 | > 67 |
68 | {pages.map((p) => ( 69 |
70 | {p} 71 |
72 | ))} 73 |
74 | { 78 | setInputValue(value) 79 | }} 80 | /> 81 | 82 | No results found. 83 | {activePage === 'home' && setPages([...pages, 'projects'])} />} 84 | {activePage === 'projects' && } 85 | 86 |
87 |
88 | ) 89 | } 90 | 91 | function Home({ searchProjects }: { searchProjects: Function }) { 92 | return ( 93 | <> 94 | 95 | { 98 | searchProjects() 99 | }} 100 | > 101 | 102 | Search Projects... 103 | 104 | 105 | 106 | Create New Project... 107 | 108 | 109 | 110 | 111 | 112 | Search Teams... 113 | 114 | 115 | 116 | Create New Team... 117 | 118 | 119 | 120 | 121 | 122 | Search Docs... 123 | 124 | 125 | 126 | Send Feedback... 127 | 128 | 129 | 130 | Contact Support 131 | 132 | 133 | 134 | ) 135 | } 136 | 137 | function Projects() { 138 | return ( 139 | <> 140 | Project 1 141 | Project 2 142 | Project 3 143 | Project 4 144 | Project 5 145 | Project 6 146 | 147 | ) 148 | } 149 | 150 | function Item({ 151 | children, 152 | shortcut, 153 | onSelect = () => {}, 154 | }: { 155 | children: React.ReactNode 156 | shortcut?: string 157 | onSelect?: (value: string) => void 158 | }) { 159 | return ( 160 | 161 | {children} 162 | {shortcut && ( 163 |
164 | {shortcut.split(' ').map((key) => { 165 | return {key} 166 | })} 167 |
168 | )} 169 |
170 | ) 171 | } 172 | 173 | function ProjectsIcon() { 174 | return ( 175 | 186 | 187 | 188 | 189 | 190 | 191 | ) 192 | } 193 | 194 | function PlusIcon() { 195 | return ( 196 | 207 | 208 | 209 | 210 | ) 211 | } 212 | 213 | function TeamsIcon() { 214 | return ( 215 | 226 | 227 | 228 | 229 | 230 | 231 | ) 232 | } 233 | 234 | function CopyIcon() { 235 | return ( 236 | 247 | 248 | 249 | ) 250 | } 251 | 252 | function DocsIcon() { 253 | return ( 254 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | ) 272 | } 273 | 274 | function FeedbackIcon() { 275 | return ( 276 | 287 | 288 | 289 | ) 290 | } 291 | 292 | function ContactIcon() { 293 | return ( 294 | 305 | 306 | 307 | 308 | ) 309 | } 310 | -------------------------------------------------------------------------------- /website/components/code/code.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | border-radius: 12px; 3 | padding: 16px; 4 | backdrop-filter: blur(10px); 5 | border: 1px solid var(--gray6); 6 | position: relative; 7 | line-height: 16px; 8 | background: var(--lowContrast); 9 | white-space: pre-wrap; 10 | box-shadow: rgb(0 0 0 / 10%) 0px 5px 30px -5px; 11 | 12 | @media (prefers-color-scheme: dark) { 13 | background: var(--grayA2); 14 | } 15 | 16 | button { 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | width: 32px; 21 | height: 32px; 22 | background: var(--grayA3); 23 | border-radius: 8px; 24 | position: absolute; 25 | top: 12px; 26 | right: 12px; 27 | color: var(--gray11); 28 | cursor: copy; 29 | transition: color 150ms ease, background 150ms ease, transform 150ms ease; 30 | 31 | &:hover { 32 | color: var(--gray12); 33 | background: var(--grayA4); 34 | } 35 | 36 | &:active { 37 | color: var(--gray12); 38 | background: var(--grayA5); 39 | transform: scale(0.96); 40 | } 41 | } 42 | } 43 | 44 | .shine { 45 | @media (prefers-color-scheme: dark) { 46 | background: linear-gradient( 47 | 90deg, 48 | rgba(56, 189, 248, 0), 49 | var(--gray5) 20%, 50 | var(--gray9) 67.19%, 51 | rgba(236, 72, 153, 0) 52 | ); 53 | height: 1px; 54 | position: absolute; 55 | top: -1px; 56 | width: 97%; 57 | z-index: -1; 58 | } 59 | } 60 | 61 | @media (max-width: 640px) { 62 | .root { 63 | :global(.token-line) { 64 | font-size: 11px !important; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /website/components/code/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import copy from 'copy-to-clipboard' 3 | import Highlight, { defaultProps } from 'prism-react-renderer' 4 | import styles from './code.module.scss' 5 | import { CopyIcon } from 'components/icons' 6 | 7 | const theme = { 8 | plain: { 9 | color: 'var(--gray12)', 10 | fontSize: 12, 11 | fontFamily: 'Menlo, monospace', 12 | }, 13 | styles: [ 14 | { 15 | types: ['comment'], 16 | style: { 17 | color: 'var(--gray9)', 18 | }, 19 | }, 20 | { 21 | types: ['atrule', 'keyword', 'attr-name', 'selector'], 22 | style: { 23 | color: 'var(--gray10)', 24 | }, 25 | }, 26 | { 27 | types: ['punctuation', 'operator'], 28 | style: { 29 | color: 'var(--gray9)', 30 | }, 31 | }, 32 | { 33 | types: ['class-name', 'function', 'tag'], 34 | style: { 35 | color: 'var(--gray12)', 36 | }, 37 | }, 38 | ], 39 | } 40 | 41 | export function Code({ children }: { children: string }) { 42 | return ( 43 | 44 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 45 |
46 |           
54 |           
55 | {tokens.map((line, i) => ( 56 |
57 | {line.map((token, key) => ( 58 | 59 | ))} 60 |
61 | ))} 62 |
63 | )} 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /website/components/icons/icons.module.scss: -------------------------------------------------------------------------------- 1 | .blurLogo { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | position: relative; 6 | border-radius: 4px; 7 | overflow: hidden; 8 | box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, 0.015); 9 | 10 | .bg { 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | position: absolute; 15 | z-index: 1; 16 | pointer-events: none; 17 | user-select: none; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | height: 100%; 22 | transform: scale(1.5) translateZ(0); 23 | filter: blur(12px) opacity(0.4) saturate(100%); 24 | transition: filter 150ms ease; 25 | } 26 | 27 | .inner { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | object-fit: cover; 32 | width: 100%; 33 | height: 100%; 34 | user-select: none; 35 | pointer-events: none; 36 | border-radius: inherit; 37 | z-index: 2; 38 | 39 | svg { 40 | width: 14px; 41 | height: 14px; 42 | filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.16)); 43 | transition: filter 150ms ease; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /website/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './icons.module.scss' 2 | 3 | export function FigmaIcon() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | export function RaycastIcon() { 16 | return ( 17 | 18 | 24 | 25 | ) 26 | } 27 | 28 | export function YouTubeIcon() { 29 | return ( 30 | 31 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export function SlackIcon() { 41 | return ( 42 | 43 | 47 | 51 | 55 | 59 | 63 | 67 | 71 | 75 | 76 | ) 77 | } 78 | 79 | export function VercelIcon() { 80 | return ( 81 | 82 | 83 | 84 | ) 85 | } 86 | 87 | export function LinearIcon({ style }: { style?: Object }) { 88 | return ( 89 | 90 | 94 | 98 | 102 | 106 | 107 | ) 108 | } 109 | 110 | export function Logo({ children, size = '20px' }: { children: React.ReactNode; size?: string }) { 111 | return ( 112 |
119 |
120 | {children} 121 |
122 |
{children}
123 |
124 | ) 125 | } 126 | 127 | export function CopyIcon() { 128 | return ( 129 | 130 | 136 | 142 | 143 | ) 144 | } 145 | 146 | export function CopiedIcon() { 147 | return ( 148 | 149 | 150 | 151 | ) 152 | } 153 | 154 | export function GitHubIcon() { 155 | return ( 156 | 157 | 161 | 162 | ) 163 | } 164 | 165 | export function FramerIcon() { 166 | return ( 167 | 168 | 172 | 173 | ) 174 | } 175 | -------------------------------------------------------------------------------- /website/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cmdk/framer' 2 | export * from './cmdk/linear' 3 | export * from './cmdk/vercel' 4 | export * from './cmdk/raycast' 5 | export * from './icons' 6 | export * from './code' 7 | -------------------------------------------------------------------------------- /website/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /website/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cmdk-website", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "pnpm -F 'cmdk-website^...' build && next build", 8 | "start": "next start", 9 | "lint": "eslint --ext .tsx" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-popover": "^0.1.6", 13 | "cmdk": "workspace:*", 14 | "copy-to-clipboard": "^3.3.1", 15 | "framer-motion": "^6.5.1", 16 | "next": "13.5.1", 17 | "next-seo": "^5.5.0", 18 | "next-themes": "^0.2.0", 19 | "prism-react-renderer": "^1.3.5", 20 | "react": "18.2.0", 21 | "react-dom": "18.2.0", 22 | "sass": "^1.53.0" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "18.0.4", 26 | "@types/react": "18.0.15", 27 | "@types/react-dom": "18.0.6", 28 | "babel-eslint": "^10.1.0", 29 | "eslint": "^8.19.0", 30 | "eslint-config-next": "12.2.2", 31 | "eslint-config-prettier": "^8.5.0", 32 | "eslint-plugin-prettier": "^4.2.1", 33 | "eslint-plugin-react": "^7.30.1", 34 | "husky": "^8.0.1", 35 | "lint-staged": "^13.0.3", 36 | "typescript": "4.7.4" 37 | }, 38 | "lint-staged": { 39 | "*.{tsx},*.{ts},*.{mdx}": [ 40 | "eslint --fix" 41 | ] 42 | }, 43 | "husky": { 44 | "hooks": { 45 | "pre-commit": "lint-staged" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /website/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'styles/globals.scss' 2 | 3 | import 'styles/cmdk/vercel.scss' 4 | import 'styles/cmdk/linear.scss' 5 | import 'styles/cmdk/raycast.scss' 6 | import 'styles/cmdk/framer.scss' 7 | 8 | import type { AppProps } from 'next/app' 9 | import { ThemeProvider } from 'next-themes' 10 | import { NextSeo } from 'next-seo' 11 | import Head from 'next/head' 12 | 13 | const title = '⌘K' 14 | const description = 'Fast, composable, unstyled command menu for React' 15 | const siteUrl = 'https://cmdk.paco.me' 16 | 17 | export default function App({ Component, pageProps }: AppProps) { 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /website/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-sync-scripts */ 2 | import React from 'react' 3 | import NextDocument, { Html, Head, Main, NextScript } from 'next/document' 4 | 5 | export default class Document extends NextDocument { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /website/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from 'styles/index.module.scss' 2 | import React from 'react' 3 | import { AnimatePresence, AnimateSharedLayout, motion, MotionProps, useInView } from 'framer-motion' 4 | import { 5 | FramerCMDK, 6 | LinearCMDK, 7 | LinearIcon, 8 | VercelCMDK, 9 | VercelIcon, 10 | RaycastCMDK, 11 | RaycastIcon, 12 | CopyIcon, 13 | FramerIcon, 14 | GitHubIcon, 15 | Code, 16 | CopiedIcon, 17 | } from 'components' 18 | import packageJSON from '../../cmdk/package.json' 19 | 20 | type TTheme = { 21 | theme: Themes 22 | setTheme: Function 23 | } 24 | 25 | type Themes = 'linear' | 'raycast' | 'vercel' | 'framer' 26 | 27 | const ThemeContext = React.createContext({} as TTheme) 28 | 29 | export default function Index() { 30 | const [theme, setTheme] = React.useState('raycast') 31 | 32 | return ( 33 |
34 |
35 |
36 |
37 | 38 |

⌘K

39 |

Fast, composable, unstyled command menu for React.

40 |
41 | 42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 | {theme === 'framer' && ( 50 | 51 | 52 | 53 | )} 54 | {theme === 'vercel' && ( 55 | 56 | 57 | 58 | )} 59 | {theme === 'linear' && ( 60 | 61 | 62 | 63 | )} 64 | {theme === 'raycast' && ( 65 | 66 | 67 | 68 | )} 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 | 77 | 78 |
79 |
80 |
81 | ) 82 | } 83 | 84 | function CMDKWrapper(props: MotionProps & { children: React.ReactNode }) { 85 | return ( 86 | 96 | ) 97 | } 98 | 99 | ////////////////////////////////////////////////////////////////// 100 | 101 | function InstallButton() { 102 | const [copied, setCopied] = React.useState(false) 103 | 104 | return ( 105 | 120 | ) 121 | } 122 | 123 | function GitHubButton() { 124 | return ( 125 | 131 | 132 | pacocoursey/cmdk 133 | 134 | ) 135 | } 136 | 137 | ////////////////////////////////////////////////////////////////// 138 | 139 | const themes = [ 140 | { 141 | icon: , 142 | key: 'raycast', 143 | }, 144 | { 145 | icon: , 146 | key: 'linear', 147 | }, 148 | { 149 | icon: , 150 | key: 'vercel', 151 | }, 152 | { 153 | icon: , 154 | key: 'framer', 155 | }, 156 | ] 157 | 158 | function ThemeSwitcher() { 159 | const { theme, setTheme } = React.useContext(ThemeContext) 160 | const ref = React.useRef(null) 161 | const [showArrowKeyHint, setShowArrowKeyHint] = React.useState(false) 162 | 163 | React.useEffect(() => { 164 | function listener(e: KeyboardEvent) { 165 | const themeNames = themes.map((t) => t.key) 166 | 167 | if (e.key === 'ArrowRight') { 168 | const currentIndex = themeNames.indexOf(theme) 169 | const nextIndex = currentIndex + 1 170 | const nextItem = themeNames[nextIndex] 171 | 172 | if (nextItem) { 173 | setTheme(nextItem) 174 | } 175 | } 176 | 177 | if (e.key === 'ArrowLeft') { 178 | const currentIndex = themeNames.indexOf(theme) 179 | const prevIndex = currentIndex - 1 180 | const prevItem = themeNames[prevIndex] 181 | 182 | if (prevItem) { 183 | setTheme(prevItem) 184 | } 185 | } 186 | } 187 | 188 | document.addEventListener('keydown', listener) 189 | 190 | return () => { 191 | document.removeEventListener('keydown', listener) 192 | } 193 | // eslint-disable-next-line react-hooks/exhaustive-deps 194 | }, [theme]) 195 | 196 | return ( 197 |
198 | 209 | ← 210 | 211 | 212 | {themes.map(({ key, icon }) => { 213 | const isActive = theme === key 214 | return ( 215 | 241 | ) 242 | })} 243 | 244 | 255 | → 256 | 257 |
258 | ) 259 | } 260 | ////////////////////////////////////////////////////////////////// 261 | 262 | function Codeblock() { 263 | const code = `import { Command } from 'cmdk'; 264 | 265 | 266 | 267 | 268 | 269 | {loading && Hang on…} 270 | 271 | No results found. 272 | 273 | 274 | Apple 275 | Orange 276 | 277 | Pear 278 | Blueberry 279 | 280 | 281 | Fish 282 | 283 | ` 284 | 285 | return ( 286 |
287 |
288 |
289 | {code} 290 |
291 | ) 292 | } 293 | 294 | ////////////////////////////////////////////////////////////////// 295 | 296 | function VersionBadge() { 297 | return v{packageJSON.version} 298 | } 299 | 300 | function Footer() { 301 | const ref = React.useRef(null) 302 | const isInView = useInView(ref, { 303 | once: true, 304 | margin: '100px', 305 | }) 306 | return ( 307 | 323 | ) 324 | } 325 | 326 | function RaunoSignature() { 327 | return ( 328 | 339 | 346 | 347 | ) 348 | } 349 | 350 | function PacoSignature() { 351 | return ( 352 | 363 | 369 | 375 | 382 | 383 | ) 384 | } 385 | -------------------------------------------------------------------------------- /website/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /website/public/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /website/public/inter-var-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pacocoursey/cmdk/fb4ea04e9ec211777fbb39c6104e3c5f2ee107d2/website/public/inter-var-latin.woff2 -------------------------------------------------------------------------------- /website/public/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /website/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pacocoursey/cmdk/fb4ea04e9ec211777fbb39c6104e3c5f2ee107d2/website/public/og.png -------------------------------------------------------------------------------- /website/public/paco.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pacocoursey/cmdk/fb4ea04e9ec211777fbb39c6104e3c5f2ee107d2/website/public/paco.png -------------------------------------------------------------------------------- /website/public/rauno.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pacocoursey/cmdk/fb4ea04e9ec211777fbb39c6104e3c5f2ee107d2/website/public/rauno.jpeg -------------------------------------------------------------------------------- /website/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /website/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /website/styles/cmdk/framer.scss: -------------------------------------------------------------------------------- 1 | .framer { 2 | [cmdk-root] { 3 | max-width: 640px; 4 | width: 100%; 5 | padding: 8px; 6 | background: #ffffff; 7 | border-radius: 16px; 8 | overflow: hidden; 9 | font-family: var(--font-sans); 10 | border: 1px solid var(--gray6); 11 | box-shadow: var(--cmdk-shadow); 12 | outline: none; 13 | 14 | .dark & { 15 | background: var(--gray2); 16 | } 17 | } 18 | 19 | [cmdk-framer-header] { 20 | display: flex; 21 | align-items: center; 22 | gap: 8px; 23 | height: 48px; 24 | padding: 0 8px; 25 | border-bottom: 1px solid var(--gray5); 26 | margin-bottom: 12px; 27 | padding-bottom: 8px; 28 | 29 | svg { 30 | width: 20px; 31 | height: 20px; 32 | color: var(--gray9); 33 | transform: translateY(1px); 34 | } 35 | } 36 | 37 | [cmdk-input] { 38 | font-family: var(--font-sans); 39 | border: none; 40 | width: 100%; 41 | font-size: 16px; 42 | outline: none; 43 | background: var(--bg); 44 | color: var(--gray12); 45 | 46 | &::placeholder { 47 | color: var(--gray9); 48 | } 49 | } 50 | 51 | [cmdk-item] { 52 | content-visibility: auto; 53 | 54 | cursor: pointer; 55 | border-radius: 12px; 56 | font-size: 14px; 57 | display: flex; 58 | align-items: center; 59 | gap: 12px; 60 | color: var(--gray12); 61 | padding: 8px 8px; 62 | margin-right: 8px; 63 | font-weight: 500; 64 | transition: all 150ms ease; 65 | transition-property: none; 66 | 67 | &[data-selected='true'] { 68 | background: var(--blue9); 69 | color: #ffffff; 70 | 71 | [cmdk-framer-item-subtitle] { 72 | color: #ffffff; 73 | } 74 | } 75 | 76 | &[data-disabled='true'] { 77 | color: var(--gray8); 78 | cursor: not-allowed; 79 | } 80 | 81 | & + [cmdk-item] { 82 | margin-top: 4px; 83 | } 84 | 85 | svg { 86 | width: 16px; 87 | height: 16px; 88 | color: #ffffff; 89 | } 90 | } 91 | 92 | [cmdk-framer-icon-wrapper] { 93 | display: flex; 94 | align-items: center; 95 | justify-content: center; 96 | min-width: 32px; 97 | height: 32px; 98 | background: orange; 99 | border-radius: 8px; 100 | } 101 | 102 | [cmdk-framer-item-meta] { 103 | display: flex; 104 | flex-direction: column; 105 | gap: 4px; 106 | } 107 | 108 | [cmdk-framer-item-subtitle] { 109 | font-size: 12px; 110 | font-weight: 400; 111 | color: var(--gray11); 112 | } 113 | 114 | [cmdk-framer-items] { 115 | min-height: 308px; 116 | display: flex; 117 | } 118 | 119 | [cmdk-framer-left] { 120 | width: 40%; 121 | } 122 | 123 | [cmdk-framer-separator] { 124 | width: 1px; 125 | border: 0; 126 | margin-right: 8px; 127 | background: var(--gray6); 128 | } 129 | 130 | [cmdk-framer-right] { 131 | display: flex; 132 | align-items: center; 133 | justify-content: center; 134 | border-radius: 8px; 135 | margin-left: 8px; 136 | width: 60%; 137 | 138 | button { 139 | width: 120px; 140 | height: 40px; 141 | background: var(--blue9); 142 | border-radius: 6px; 143 | font-weight: 500; 144 | color: white; 145 | font-size: 14px; 146 | } 147 | 148 | input[type='text'] { 149 | height: 40px; 150 | width: 160px; 151 | border: 1px solid var(--gray6); 152 | background: #ffffff; 153 | border-radius: 6px; 154 | padding: 0 8px; 155 | font-size: 14px; 156 | font-family: var(--font-sans); 157 | box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.08); 158 | 159 | &::placeholder { 160 | color: var(--gray9); 161 | } 162 | 163 | @media (prefers-color-scheme: dark) { 164 | background: var(--gray3); 165 | } 166 | } 167 | 168 | [cmdk-framer-radio] { 169 | display: flex; 170 | align-items: center; 171 | gap: 4px; 172 | color: var(--gray12); 173 | font-weight: 500; 174 | font-size: 14px; 175 | accent-color: var(--blue9); 176 | 177 | input { 178 | width: 20px; 179 | height: 20px; 180 | } 181 | } 182 | 183 | img { 184 | width: 40px; 185 | height: 40px; 186 | border-radius: 9999px; 187 | border: 1px solid var(--gray6); 188 | } 189 | 190 | [cmdk-framer-container] { 191 | width: 100px; 192 | height: 100px; 193 | background: var(--blue9); 194 | border-radius: 16px; 195 | } 196 | 197 | [cmdk-framer-badge] { 198 | background: var(--blue3); 199 | padding: 0 8px; 200 | height: 28px; 201 | font-size: 14px; 202 | line-height: 28px; 203 | color: var(--blue11); 204 | border-radius: 9999px; 205 | font-weight: 500; 206 | } 207 | 208 | [cmdk-framer-slider] { 209 | height: 20px; 210 | width: 200px; 211 | background: linear-gradient(90deg, var(--blue9) 40%, var(--gray3) 0%); 212 | border-radius: 9999px; 213 | 214 | div { 215 | width: 20px; 216 | height: 20px; 217 | background: #ffffff; 218 | border-radius: 9999px; 219 | box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.32); 220 | transform: translateX(70px); 221 | } 222 | } 223 | } 224 | 225 | [cmdk-list] { 226 | overflow: auto; 227 | } 228 | 229 | [cmdk-separator] { 230 | height: 1px; 231 | width: 100%; 232 | background: var(--gray5); 233 | margin: 4px 0; 234 | } 235 | 236 | [cmdk-group-heading] { 237 | user-select: none; 238 | font-size: 12px; 239 | color: var(--gray11); 240 | padding: 0 8px; 241 | display: flex; 242 | align-items: center; 243 | margin-bottom: 8px; 244 | } 245 | 246 | [cmdk-empty] { 247 | font-size: 14px; 248 | padding: 32px; 249 | white-space: pre-wrap; 250 | color: var(--gray11); 251 | } 252 | } 253 | 254 | @media (max-width: 640px) { 255 | .framer { 256 | [cmdk-framer-icon-wrapper] { 257 | } 258 | 259 | [cmdk-framer-item-subtitle] { 260 | display: none; 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /website/styles/cmdk/linear.scss: -------------------------------------------------------------------------------- 1 | .linear { 2 | [cmdk-root] { 3 | max-width: 640px; 4 | width: 100%; 5 | background: #ffffff; 6 | border-radius: 8px; 7 | overflow: hidden; 8 | padding: 0; 9 | font-family: var(--font-sans); 10 | box-shadow: var(--cmdk-shadow); 11 | outline: none; 12 | 13 | .dark & { 14 | background: linear-gradient(136.61deg, rgb(39, 40, 43) 13.72%, rgb(45, 46, 49) 74.3%); 15 | } 16 | } 17 | 18 | [cmdk-linear-badge] { 19 | height: 24px; 20 | padding: 0 8px; 21 | font-size: 12px; 22 | color: var(--gray11); 23 | background: var(--gray3); 24 | border-radius: 4px; 25 | width: fit-content; 26 | display: flex; 27 | align-items: center; 28 | margin: 16px 16px 0; 29 | } 30 | 31 | [cmdk-linear-shortcuts] { 32 | display: flex; 33 | margin-left: auto; 34 | gap: 8px; 35 | 36 | kbd { 37 | font-family: var(--font-sans); 38 | font-size: 13px; 39 | color: var(--gray11); 40 | } 41 | } 42 | 43 | [cmdk-input] { 44 | font-family: var(--font-sans); 45 | border: none; 46 | width: 100%; 47 | font-size: 18px; 48 | padding: 20px; 49 | outline: none; 50 | background: var(--bg); 51 | color: var(--gray12); 52 | border-bottom: 1px solid var(--gray6); 53 | border-radius: 0; 54 | caret-color: #6e5ed2; 55 | margin: 0; 56 | 57 | &::placeholder { 58 | color: var(--gray9); 59 | } 60 | } 61 | 62 | [cmdk-item] { 63 | content-visibility: auto; 64 | 65 | cursor: pointer; 66 | height: 48px; 67 | font-size: 14px; 68 | display: flex; 69 | align-items: center; 70 | gap: 12px; 71 | padding: 0 16px; 72 | color: var(--gray12); 73 | user-select: none; 74 | will-change: background, color; 75 | transition: all 150ms ease; 76 | transition-property: none; 77 | position: relative; 78 | 79 | &[data-selected='true'] { 80 | background: var(--gray3); 81 | 82 | svg { 83 | color: var(--gray12); 84 | } 85 | 86 | &:after { 87 | content: ''; 88 | position: absolute; 89 | left: 0; 90 | z-index: 123; 91 | width: 3px; 92 | height: 100%; 93 | background: #5f6ad2; 94 | } 95 | } 96 | 97 | &[data-disabled='true'] { 98 | color: var(--gray8); 99 | cursor: not-allowed; 100 | } 101 | 102 | &:active { 103 | transition-property: background; 104 | background: var(--gray4); 105 | } 106 | 107 | & + [cmdk-item] { 108 | margin-top: 4px; 109 | } 110 | 111 | svg { 112 | width: 16px; 113 | height: 16px; 114 | color: var(--gray10); 115 | } 116 | } 117 | 118 | [cmdk-list] { 119 | height: min(300px, var(--cmdk-list-height)); 120 | max-height: 400px; 121 | overflow: auto; 122 | overscroll-behavior: contain; 123 | transition: 100ms ease; 124 | transition-property: height; 125 | } 126 | 127 | [cmdk-group-heading] { 128 | user-select: none; 129 | font-size: 12px; 130 | color: var(--gray11); 131 | padding: 0 8px; 132 | display: flex; 133 | align-items: center; 134 | } 135 | 136 | [cmdk-empty] { 137 | font-size: 14px; 138 | display: flex; 139 | align-items: center; 140 | justify-content: center; 141 | height: 64px; 142 | white-space: pre-wrap; 143 | color: var(--gray11); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /website/styles/cmdk/raycast.scss: -------------------------------------------------------------------------------- 1 | .raycast { 2 | [cmdk-root] { 3 | max-width: 640px; 4 | width: 100%; 5 | background: var(--gray1); 6 | border-radius: 12px; 7 | padding: 8px 0; 8 | font-family: var(--font-sans); 9 | box-shadow: var(--cmdk-shadow); 10 | border: 1px solid var(--gray6); 11 | position: relative; 12 | outline: none; 13 | 14 | .dark & { 15 | background: var(--gray2); 16 | border: 0; 17 | 18 | &:after { 19 | content: ''; 20 | background: linear-gradient( 21 | to right, 22 | var(--gray6) 20%, 23 | var(--gray6) 40%, 24 | var(--gray10) 50%, 25 | var(--gray10) 55%, 26 | var(--gray6) 70%, 27 | var(--gray6) 100% 28 | ); 29 | z-index: -1; 30 | position: absolute; 31 | border-radius: 12px; 32 | top: -1px; 33 | left: -1px; 34 | width: calc(100% + 2px); 35 | height: calc(100% + 2px); 36 | animation: shine 3s ease forwards 0.1s; 37 | background-size: 200% auto; 38 | } 39 | 40 | &:before { 41 | content: ''; 42 | z-index: -1; 43 | position: absolute; 44 | border-radius: 12px; 45 | top: -1px; 46 | left: -1px; 47 | width: calc(100% + 2px); 48 | height: calc(100% + 2px); 49 | box-shadow: 0 0 0 1px transparent; 50 | animation: border 1s linear forwards 0.5s; 51 | } 52 | } 53 | 54 | kbd { 55 | font-family: var(--font-sans); 56 | background: var(--gray3); 57 | color: var(--gray11); 58 | height: 20px; 59 | width: 20px; 60 | border-radius: 4px; 61 | padding: 0 4px; 62 | display: flex; 63 | align-items: center; 64 | justify-content: center; 65 | 66 | &:first-of-type { 67 | margin-left: 8px; 68 | } 69 | } 70 | } 71 | 72 | [cmdk-input] { 73 | font-family: var(--font-sans); 74 | border: none; 75 | width: 100%; 76 | font-size: 15px; 77 | padding: 8px 16px; 78 | outline: none; 79 | background: var(--bg); 80 | color: var(--gray12); 81 | 82 | &::placeholder { 83 | color: var(--gray9); 84 | } 85 | } 86 | 87 | [cmdk-raycast-top-shine] { 88 | .dark & { 89 | background: linear-gradient( 90 | 90deg, 91 | rgba(56, 189, 248, 0), 92 | var(--gray5) 20%, 93 | var(--gray9) 67.19%, 94 | rgba(236, 72, 153, 0) 95 | ); 96 | height: 1px; 97 | position: absolute; 98 | top: -1px; 99 | width: 100%; 100 | z-index: -1; 101 | opacity: 0; 102 | animation: showTopShine 0.1s ease forwards 0.2s; 103 | } 104 | } 105 | 106 | [cmdk-raycast-loader] { 107 | --loader-color: var(--gray9); 108 | border: 0; 109 | width: 100%; 110 | width: 100%; 111 | left: 0; 112 | height: 1px; 113 | background: var(--gray6); 114 | position: relative; 115 | overflow: visible; 116 | display: block; 117 | margin-top: 12px; 118 | margin-bottom: 12px; 119 | 120 | &:after { 121 | content: ''; 122 | width: 50%; 123 | height: 1px; 124 | position: absolute; 125 | background: linear-gradient(90deg, transparent 0%, var(--loader-color) 50%, transparent 100%); 126 | top: -1px; 127 | opacity: 0; 128 | animation-duration: 1.5s; 129 | animation-delay: 1s; 130 | animation-timing-function: ease; 131 | animation-name: loading; 132 | } 133 | } 134 | 135 | [cmdk-item] { 136 | content-visibility: auto; 137 | 138 | cursor: pointer; 139 | height: 40px; 140 | border-radius: 8px; 141 | font-size: 14px; 142 | display: flex; 143 | align-items: center; 144 | gap: 8px; 145 | padding: 0 8px; 146 | color: var(--gray12); 147 | user-select: none; 148 | will-change: background, color; 149 | transition: all 150ms ease; 150 | transition-property: none; 151 | 152 | &[data-selected='true'] { 153 | background: var(--gray4); 154 | color: var(--gray12); 155 | } 156 | 157 | &[data-disabled='true'] { 158 | color: var(--gray8); 159 | cursor: not-allowed; 160 | } 161 | 162 | &:active { 163 | transition-property: background; 164 | background: var(--gray4); 165 | } 166 | 167 | &:first-child { 168 | margin-top: 8px; 169 | } 170 | 171 | & + [cmdk-item] { 172 | margin-top: 4px; 173 | } 174 | 175 | svg { 176 | width: 18px; 177 | height: 18px; 178 | } 179 | } 180 | 181 | [cmdk-raycast-meta] { 182 | margin-left: auto; 183 | color: var(--gray11); 184 | font-size: 13px; 185 | } 186 | 187 | [cmdk-list] { 188 | padding: 0 8px; 189 | height: 393px; 190 | overflow: auto; 191 | overscroll-behavior: contain; 192 | scroll-padding-block-end: 40px; 193 | transition: 100ms ease; 194 | transition-property: height; 195 | padding-bottom: 40px; 196 | } 197 | 198 | [cmdk-raycast-open-trigger], 199 | [cmdk-raycast-subcommand-trigger] { 200 | color: var(--gray11); 201 | padding: 0px 4px 0px 8px; 202 | border-radius: 6px; 203 | font-weight: 500; 204 | font-size: 12px; 205 | height: 28px; 206 | letter-spacing: -0.25px; 207 | } 208 | 209 | [cmdk-raycast-clipboard-icon], 210 | [cmdk-raycast-hammer-icon] { 211 | width: 20px; 212 | height: 20px; 213 | border-radius: 6px; 214 | display: flex; 215 | align-items: center; 216 | justify-content: center; 217 | color: #ffffff; 218 | 219 | svg { 220 | width: 14px; 221 | height: 14px; 222 | } 223 | } 224 | 225 | [cmdk-raycast-clipboard-icon] { 226 | background: linear-gradient(to bottom, #f55354, #eb4646); 227 | } 228 | 229 | [cmdk-raycast-hammer-icon] { 230 | background: linear-gradient(to bottom, #6cb9a3, #2c6459); 231 | } 232 | 233 | [cmdk-raycast-open-trigger] { 234 | display: flex; 235 | align-items: center; 236 | color: var(--gray12); 237 | } 238 | 239 | [cmdk-raycast-subcommand-trigger] { 240 | display: flex; 241 | align-items: center; 242 | gap: 4px; 243 | right: 8px; 244 | bottom: 8px; 245 | 246 | svg { 247 | width: 14px; 248 | height: 14px; 249 | } 250 | 251 | hr { 252 | height: 100%; 253 | background: var(--gray6); 254 | border: 0; 255 | width: 1px; 256 | } 257 | 258 | &[aria-expanded='true'], 259 | &:hover { 260 | background: var(--gray4); 261 | 262 | kbd { 263 | background: var(--gray7); 264 | } 265 | } 266 | } 267 | 268 | [cmdk-separator] { 269 | height: 1px; 270 | width: 100%; 271 | background: var(--gray5); 272 | margin: 4px 0; 273 | } 274 | 275 | *:not([hidden]) + [cmdk-group] { 276 | margin-top: 8px; 277 | } 278 | 279 | [cmdk-group-heading] { 280 | user-select: none; 281 | font-size: 12px; 282 | color: var(--gray11); 283 | padding: 0 8px; 284 | display: flex; 285 | align-items: center; 286 | } 287 | 288 | [cmdk-raycast-footer] { 289 | display: flex; 290 | height: 40px; 291 | align-items: center; 292 | width: 100%; 293 | position: absolute; 294 | background: var(--gray1); 295 | bottom: 0; 296 | padding: 8px; 297 | border-top: 1px solid var(--gray6); 298 | border-radius: 0 0 12px 12px; 299 | 300 | svg { 301 | width: 20px; 302 | height: 20px; 303 | filter: grayscale(1); 304 | margin-right: auto; 305 | } 306 | 307 | hr { 308 | height: 12px; 309 | width: 1px; 310 | border: 0; 311 | background: var(--gray6); 312 | margin: 0 4px 0px 12px; 313 | } 314 | 315 | @media (prefers-color-scheme: dark) { 316 | background: var(--gray2); 317 | } 318 | } 319 | 320 | [cmdk-dialog] { 321 | z-index: var(--layer-portal); 322 | position: fixed; 323 | left: 50%; 324 | top: var(--page-top); 325 | transform: translateX(-50%); 326 | 327 | [cmdk] { 328 | width: 640px; 329 | transform-origin: center center; 330 | animation: dialogIn var(--transition-fast) forwards; 331 | } 332 | 333 | &[data-state='closed'] [cmdk] { 334 | animation: dialogOut var(--transition-fast) forwards; 335 | } 336 | } 337 | 338 | [cmdk-empty] { 339 | font-size: 14px; 340 | display: flex; 341 | align-items: center; 342 | justify-content: center; 343 | height: 64px; 344 | white-space: pre-wrap; 345 | color: var(--gray11); 346 | } 347 | } 348 | 349 | @keyframes loading { 350 | 0% { 351 | opacity: 0; 352 | transform: translateX(0); 353 | } 354 | 355 | 50% { 356 | opacity: 1; 357 | transform: translateX(100%); 358 | } 359 | 360 | 100% { 361 | opacity: 0; 362 | transform: translateX(0); 363 | } 364 | } 365 | 366 | @keyframes shine { 367 | to { 368 | background-position: 200% center; 369 | opacity: 0; 370 | } 371 | } 372 | 373 | @keyframes border { 374 | to { 375 | box-shadow: 0 0 0 1px var(--gray6); 376 | } 377 | } 378 | 379 | @keyframes showTopShine { 380 | to { 381 | opacity: 1; 382 | } 383 | } 384 | 385 | .raycast-submenu { 386 | [cmdk-root] { 387 | display: flex; 388 | flex-direction: column; 389 | width: 320px; 390 | border: 1px solid var(--gray6); 391 | background: var(--gray2); 392 | border-radius: 8px; 393 | } 394 | 395 | [cmdk-list] { 396 | padding: 8px; 397 | overflow: auto; 398 | overscroll-behavior: contain; 399 | transition: 100ms ease; 400 | transition-property: height; 401 | } 402 | 403 | [cmdk-item] { 404 | height: 40px; 405 | 406 | cursor: pointer; 407 | height: 40px; 408 | border-radius: 8px; 409 | font-size: 13px; 410 | display: flex; 411 | align-items: center; 412 | gap: 8px; 413 | padding: 0 8px; 414 | color: var(--gray12); 415 | user-select: none; 416 | will-change: background, color; 417 | transition: all 150ms ease; 418 | transition-property: none; 419 | 420 | &[aria-selected='true'] { 421 | background: var(--gray5); 422 | color: var(--gray12); 423 | 424 | [cmdk-raycast-submenu-shortcuts] kbd { 425 | background: var(--gray7); 426 | } 427 | } 428 | 429 | &[aria-disabled='true'] { 430 | color: var(--gray8); 431 | cursor: not-allowed; 432 | } 433 | 434 | svg { 435 | width: 16px; 436 | height: 16px; 437 | } 438 | 439 | [cmdk-raycast-submenu-shortcuts] { 440 | display: flex; 441 | margin-left: auto; 442 | gap: 2px; 443 | 444 | kbd { 445 | font-family: var(--font-sans); 446 | background: var(--gray5); 447 | color: var(--gray11); 448 | height: 20px; 449 | width: 20px; 450 | border-radius: 4px; 451 | padding: 0 4px; 452 | font-size: 12px; 453 | display: flex; 454 | align-items: center; 455 | justify-content: center; 456 | 457 | &:first-of-type { 458 | margin-left: 8px; 459 | } 460 | } 461 | } 462 | } 463 | 464 | [cmdk-group-heading] { 465 | text-transform: capitalize; 466 | font-size: 12px; 467 | color: var(--gray11); 468 | font-weight: 500; 469 | margin-bottom: 8px; 470 | margin-top: 8px; 471 | margin-left: 4px; 472 | } 473 | 474 | [cmdk-input] { 475 | padding: 12px; 476 | font-family: var(--font-sans); 477 | border: 0; 478 | border-top: 1px solid var(--gray6); 479 | font-size: 13px; 480 | background: transparent; 481 | margin-top: auto; 482 | width: 100%; 483 | outline: 0; 484 | border-radius: 0; 485 | } 486 | 487 | animation-duration: 0.2s; 488 | animation-timing-function: ease; 489 | animation-fill-mode: forwards; 490 | transform-origin: var(--radix-popover-content-transform-origin); 491 | 492 | &[data-state='open'] { 493 | animation-name: slideIn; 494 | } 495 | 496 | &[data-state='closed'] { 497 | animation-name: slideOut; 498 | } 499 | 500 | [cmdk-empty] { 501 | display: flex; 502 | align-items: center; 503 | justify-content: center; 504 | height: 64px; 505 | white-space: pre-wrap; 506 | font-size: 14px; 507 | color: var(--gray11); 508 | } 509 | } 510 | 511 | @keyframes slideIn { 512 | 0% { 513 | opacity: 0; 514 | transform: scale(0.96); 515 | } 516 | 517 | 100% { 518 | opacity: 1; 519 | transform: scale(1); 520 | } 521 | } 522 | 523 | @keyframes slideOut { 524 | 0% { 525 | opacity: 1; 526 | transform: scale(1); 527 | } 528 | 529 | 100% { 530 | opacity: 0; 531 | transform: scale(0.96); 532 | } 533 | } 534 | 535 | @media (max-width: 640px) { 536 | .raycast { 537 | [cmdk-input] { 538 | font-size: 16px; 539 | } 540 | } 541 | } 542 | -------------------------------------------------------------------------------- /website/styles/cmdk/vercel.scss: -------------------------------------------------------------------------------- 1 | .vercel { 2 | [cmdk-root] { 3 | max-width: 640px; 4 | width: 100%; 5 | padding: 8px; 6 | background: #ffffff; 7 | border-radius: 12px; 8 | overflow: hidden; 9 | font-family: var(--font-sans); 10 | border: 1px solid var(--gray6); 11 | box-shadow: var(--cmdk-shadow); 12 | transition: transform 100ms ease; 13 | outline: none; 14 | 15 | .dark & { 16 | background: rgba(22, 22, 22, 0.7); 17 | } 18 | } 19 | 20 | [cmdk-input] { 21 | font-family: var(--font-sans); 22 | border: none; 23 | width: 100%; 24 | font-size: 17px; 25 | padding: 8px 8px 16px 8px; 26 | outline: none; 27 | background: var(--bg); 28 | color: var(--gray12); 29 | border-bottom: 1px solid var(--gray6); 30 | margin-bottom: 16px; 31 | border-radius: 0; 32 | 33 | &::placeholder { 34 | color: var(--gray9); 35 | } 36 | } 37 | 38 | [cmdk-vercel-badge] { 39 | height: 20px; 40 | background: var(--grayA3); 41 | display: inline-flex; 42 | align-items: center; 43 | padding: 0 8px; 44 | font-size: 12px; 45 | color: var(--grayA11); 46 | border-radius: 4px; 47 | margin: 4px 0 4px 4px; 48 | user-select: none; 49 | text-transform: capitalize; 50 | font-weight: 500; 51 | } 52 | 53 | [cmdk-item] { 54 | content-visibility: auto; 55 | 56 | cursor: pointer; 57 | height: 48px; 58 | border-radius: 8px; 59 | font-size: 14px; 60 | display: flex; 61 | align-items: center; 62 | gap: 8px; 63 | padding: 0 16px; 64 | color: var(--gray11); 65 | user-select: none; 66 | will-change: background, color; 67 | transition: all 150ms ease; 68 | transition-property: none; 69 | 70 | &[data-selected='true'] { 71 | background: var(--grayA3); 72 | color: var(--gray12); 73 | } 74 | 75 | &[data-disabled='true'] { 76 | color: var(--gray8); 77 | cursor: not-allowed; 78 | } 79 | 80 | &:active { 81 | transition-property: background; 82 | background: var(--gray4); 83 | } 84 | 85 | & + [cmdk-item] { 86 | margin-top: 4px; 87 | } 88 | 89 | svg { 90 | width: 18px; 91 | height: 18px; 92 | } 93 | } 94 | 95 | [cmdk-list] { 96 | height: min(330px, calc(var(--cmdk-list-height))); 97 | max-height: 400px; 98 | overflow: auto; 99 | overscroll-behavior: contain; 100 | transition: 100ms ease; 101 | transition-property: height; 102 | } 103 | 104 | [cmdk-vercel-shortcuts] { 105 | display: flex; 106 | margin-left: auto; 107 | gap: 8px; 108 | 109 | kbd { 110 | font-family: var(--font-sans); 111 | font-size: 12px; 112 | min-width: 20px; 113 | padding: 4px; 114 | height: 20px; 115 | border-radius: 4px; 116 | color: var(--gray11); 117 | background: var(--gray4); 118 | display: inline-flex; 119 | align-items: center; 120 | justify-content: center; 121 | text-transform: uppercase; 122 | } 123 | } 124 | 125 | [cmdk-separator] { 126 | height: 1px; 127 | width: 100%; 128 | background: var(--gray5); 129 | margin: 4px 0; 130 | } 131 | 132 | *:not([hidden]) + [cmdk-group] { 133 | margin-top: 8px; 134 | } 135 | 136 | [cmdk-group-heading] { 137 | user-select: none; 138 | font-size: 12px; 139 | color: var(--gray11); 140 | padding: 0 8px; 141 | display: flex; 142 | align-items: center; 143 | margin-bottom: 8px; 144 | } 145 | 146 | [cmdk-empty] { 147 | font-size: 14px; 148 | display: flex; 149 | align-items: center; 150 | justify-content: center; 151 | height: 48px; 152 | white-space: pre-wrap; 153 | color: var(--gray11); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /website/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-style: normal; 4 | font-weight: 100 900; // Range of weights supported 5 | font-display: optional; 6 | src: url(/inter-var-latin.woff2) format('woff2'); 7 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, 8 | U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 9 | } 10 | 11 | ::selection { 12 | background: hotpink; 13 | color: white; 14 | } 15 | 16 | html, 17 | body { 18 | padding: 0; 19 | margin: 0; 20 | font-family: var(--font-sans); 21 | } 22 | 23 | body { 24 | background: var(--app-bg); 25 | overflow-x: hidden; 26 | } 27 | 28 | button { 29 | background: none; 30 | font-family: var(--font-sans); 31 | padding: 0; 32 | border: 0; 33 | } 34 | 35 | h1, 36 | h2, 37 | h3, 38 | h4, 39 | h5, 40 | h6, 41 | p { 42 | margin: 0; 43 | } 44 | 45 | a { 46 | color: inherit; 47 | text-decoration: none; 48 | } 49 | 50 | *, 51 | *::after, 52 | *::before { 53 | box-sizing: border-box; 54 | -webkit-font-smoothing: antialiased; 55 | -moz-osx-font-smoothing: grayscale; 56 | } 57 | 58 | :root { 59 | --font-sans: 'Inter', --apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, 60 | Droid Sans, Helvetica Neue, sans-serif; 61 | --app-bg: var(--gray1); 62 | --cmdk-shadow: 0 16px 70px rgb(0 0 0 / 20%); 63 | 64 | --lowContrast: #ffffff; 65 | --highContrast: #000000; 66 | 67 | --gray1: hsl(0, 0%, 99%); 68 | --gray2: hsl(0, 0%, 97.3%); 69 | --gray3: hsl(0, 0%, 95.1%); 70 | --gray4: hsl(0, 0%, 93%); 71 | --gray5: hsl(0, 0%, 90.9%); 72 | --gray6: hsl(0, 0%, 88.7%); 73 | --gray7: hsl(0, 0%, 85.8%); 74 | --gray8: hsl(0, 0%, 78%); 75 | --gray9: hsl(0, 0%, 56.1%); 76 | --gray10: hsl(0, 0%, 52.3%); 77 | --gray11: hsl(0, 0%, 43.5%); 78 | --gray12: hsl(0, 0%, 9%); 79 | 80 | --grayA1: hsla(0, 0%, 0%, 0.012); 81 | --grayA2: hsla(0, 0%, 0%, 0.027); 82 | --grayA3: hsla(0, 0%, 0%, 0.047); 83 | --grayA4: hsla(0, 0%, 0%, 0.071); 84 | --grayA5: hsla(0, 0%, 0%, 0.09); 85 | --grayA6: hsla(0, 0%, 0%, 0.114); 86 | --grayA7: hsla(0, 0%, 0%, 0.141); 87 | --grayA8: hsla(0, 0%, 0%, 0.22); 88 | --grayA9: hsla(0, 0%, 0%, 0.439); 89 | --grayA10: hsla(0, 0%, 0%, 0.478); 90 | --grayA11: hsla(0, 0%, 0%, 0.565); 91 | --grayA12: hsla(0, 0%, 0%, 0.91); 92 | 93 | --blue1: hsl(206, 100%, 99.2%); 94 | --blue2: hsl(210, 100%, 98%); 95 | --blue3: hsl(209, 100%, 96.5%); 96 | --blue4: hsl(210, 98.8%, 94%); 97 | --blue5: hsl(209, 95%, 90.1%); 98 | --blue6: hsl(209, 81.2%, 84.5%); 99 | --blue7: hsl(208, 77.5%, 76.9%); 100 | --blue8: hsl(206, 81.9%, 65.3%); 101 | --blue9: hsl(206, 100%, 50%); 102 | --blue10: hsl(208, 100%, 47.3%); 103 | --blue11: hsl(211, 100%, 43.2%); 104 | --blue12: hsl(211, 100%, 15%); 105 | } 106 | 107 | .dark { 108 | --app-bg: var(--gray1); 109 | 110 | --lowContrast: #000000; 111 | --highContrast: #ffffff; 112 | 113 | --gray1: hsl(0, 0%, 8.5%); 114 | --gray2: hsl(0, 0%, 11%); 115 | --gray3: hsl(0, 0%, 13.6%); 116 | --gray4: hsl(0, 0%, 15.8%); 117 | --gray5: hsl(0, 0%, 17.9%); 118 | --gray6: hsl(0, 0%, 20.5%); 119 | --gray7: hsl(0, 0%, 24.3%); 120 | --gray8: hsl(0, 0%, 31.2%); 121 | --gray9: hsl(0, 0%, 43.9%); 122 | --gray10: hsl(0, 0%, 49.4%); 123 | --gray11: hsl(0, 0%, 62.8%); 124 | --gray12: hsl(0, 0%, 93%); 125 | 126 | --grayA1: hsla(0, 0%, 100%, 0); 127 | --grayA2: hsla(0, 0%, 100%, 0.026); 128 | --grayA3: hsla(0, 0%, 100%, 0.056); 129 | --grayA4: hsla(0, 0%, 100%, 0.077); 130 | --grayA5: hsla(0, 0%, 100%, 0.103); 131 | --grayA6: hsla(0, 0%, 100%, 0.129); 132 | --grayA7: hsla(0, 0%, 100%, 0.172); 133 | --grayA8: hsla(0, 0%, 100%, 0.249); 134 | --grayA9: hsla(0, 0%, 100%, 0.386); 135 | --grayA10: hsla(0, 0%, 100%, 0.446); 136 | --grayA11: hsla(0, 0%, 100%, 0.592); 137 | --grayA12: hsla(0, 0%, 100%, 0.923); 138 | 139 | --blue1: hsl(212, 35%, 9.2%); 140 | --blue2: hsl(216, 50%, 11.8%); 141 | --blue3: hsl(214, 59.4%, 15.3%); 142 | --blue4: hsl(214, 65.8%, 17.9%); 143 | --blue5: hsl(213, 71.2%, 20.2%); 144 | --blue6: hsl(212, 77.4%, 23.1%); 145 | --blue7: hsl(211, 85.1%, 27.4%); 146 | --blue8: hsl(211, 89.7%, 34.1%); 147 | --blue9: hsl(206, 100%, 50%); 148 | --blue10: hsl(209, 100%, 60.6%); 149 | --blue11: hsl(210, 100%, 66.1%); 150 | --blue12: hsl(206, 98%, 95.8%); 151 | } 152 | -------------------------------------------------------------------------------- /website/styles/index.module.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | width: 100vw; 3 | min-height: 100vh; 4 | position: relative; 5 | display: flex; 6 | justify-content: center; 7 | padding: 120px 24px 160px 24px; 8 | 9 | &:before { 10 | background: radial-gradient(circle, rgba(2, 0, 36, 0) 0, var(--gray1) 100%); 11 | position: absolute; 12 | content: ''; 13 | z-index: 2; 14 | width: 100%; 15 | height: 100%; 16 | top: 0; 17 | } 18 | 19 | &:after { 20 | content: ''; 21 | background-image: url('/grid.svg'); 22 | z-index: -1; 23 | position: absolute; 24 | width: 100%; 25 | height: 100%; 26 | top: 0; 27 | opacity: 0.2; 28 | filter: invert(1); 29 | 30 | @media (prefers-color-scheme: dark) { 31 | filter: unset; 32 | } 33 | } 34 | 35 | h1 { 36 | font-size: 32px; 37 | color: var(--gray12); 38 | font-weight: 600; 39 | letter-spacing: -2px; 40 | line-height: 40px; 41 | } 42 | 43 | p { 44 | color: var(--gray11); 45 | margin-top: 8px; 46 | font-size: 16px; 47 | } 48 | } 49 | 50 | .content { 51 | height: fit-content; 52 | position: relative; 53 | z-index: 3; 54 | width: 100%; 55 | max-width: 640px; 56 | 57 | &:after { 58 | background-image: radial-gradient(at 27% 37%, hsla(215, 98%, 61%, 1) 0px, transparent 50%), 59 | radial-gradient(at 97% 21%, hsla(256, 98%, 72%, 1) 0px, transparent 50%), 60 | radial-gradient(at 52% 99%, hsla(354, 98%, 61%, 1) 0px, transparent 50%), 61 | radial-gradient(at 10% 29%, hsla(133, 96%, 67%, 1) 0px, transparent 50%), 62 | radial-gradient(at 97% 96%, hsla(38, 60%, 74%, 1) 0px, transparent 50%), 63 | radial-gradient(at 33% 50%, hsla(222, 67%, 73%, 1) 0px, transparent 50%), 64 | radial-gradient(at 79% 53%, hsla(343, 68%, 79%, 1) 0px, transparent 50%); 65 | position: absolute; 66 | content: ''; 67 | z-index: 2; 68 | width: 100%; 69 | height: 100%; 70 | filter: blur(100px) saturate(150%); 71 | z-index: -1; 72 | top: 80px; 73 | opacity: 0.2; 74 | transform: translateZ(0); 75 | 76 | @media (prefers-color-scheme: dark) { 77 | opacity: 0.1; 78 | } 79 | } 80 | } 81 | 82 | .meta { 83 | display: flex; 84 | align-items: center; 85 | justify-content: space-between; 86 | margin-bottom: 48px; 87 | flex-wrap: wrap; 88 | gap: 16px; 89 | } 90 | 91 | .buttons { 92 | display: flex; 93 | flex-direction: column; 94 | align-items: flex-end; 95 | gap: 12px; 96 | transform: translateY(12px); 97 | } 98 | 99 | .githubButton, 100 | .installButton, 101 | .switcher button { 102 | height: 40px; 103 | color: var(--gray12); 104 | border-radius: 9999px; 105 | font-size: 14px; 106 | transition-duration: 150ms; 107 | transition-property: background, color, transform; 108 | transition-timing-function: ease-in; 109 | will-change: transform; 110 | } 111 | 112 | .githubButton { 113 | width: 177px; 114 | padding: 0 12px; 115 | display: inline-flex; 116 | align-items: center; 117 | gap: 8px; 118 | font-weight: 500; 119 | 120 | &:hover { 121 | background: var(--grayA3); 122 | } 123 | 124 | &:active { 125 | background: var(--grayA5); 126 | transform: scale(0.97); 127 | } 128 | 129 | &:focus-visible { 130 | outline: 0; 131 | outline: 2px solid var(--gray7); 132 | } 133 | } 134 | 135 | .installButton { 136 | background: var(--grayA3); 137 | display: flex; 138 | align-items: center; 139 | gap: 16px; 140 | padding: 0px 8px 0 16px; 141 | cursor: copy; 142 | font-weight: 500; 143 | 144 | &:hover { 145 | background: var(--grayA4); 146 | 147 | span { 148 | background: var(--grayA5); 149 | 150 | svg { 151 | color: var(--gray12); 152 | } 153 | } 154 | } 155 | 156 | &:focus-visible { 157 | outline: 0; 158 | outline: 2px solid var(--gray7); 159 | outline-offset: 2px; 160 | } 161 | 162 | &:active { 163 | background: var(--gray5); 164 | transform: scale(0.97); 165 | } 166 | 167 | span { 168 | width: 28px; 169 | height: 28px; 170 | display: flex; 171 | align-items: center; 172 | justify-content: center; 173 | margin-left: auto; 174 | background: var(--grayA3); 175 | border-radius: 9999px; 176 | transition: background 150ms ease; 177 | 178 | svg { 179 | size: 16px; 180 | color: var(--gray11); 181 | transition: color 150ms ease; 182 | } 183 | } 184 | } 185 | 186 | .switcher { 187 | display: grid; 188 | grid-template-columns: repeat(4, 100px); 189 | align-items: center; 190 | justify-content: center; 191 | gap: 4px; 192 | margin-top: 48px; 193 | position: relative; 194 | 195 | button { 196 | height: 32px; 197 | line-height: 32px; 198 | display: flex; 199 | align-items: center; 200 | margin: auto; 201 | gap: 8px; 202 | padding: 0 16px; 203 | border-radius: 9999px; 204 | color: var(--gray11); 205 | font-size: 14px; 206 | cursor: pointer; 207 | user-select: none; 208 | position: relative; 209 | text-transform: capitalize; 210 | 211 | &:hover { 212 | color: var(--gray12); 213 | } 214 | 215 | &:active { 216 | transform: scale(0.96); 217 | } 218 | 219 | &:focus-visible { 220 | outline: 0; 221 | outline: 2px solid var(--gray7); 222 | } 223 | 224 | svg { 225 | width: 14px; 226 | height: 14px; 227 | } 228 | 229 | &[data-selected='true'] { 230 | color: var(--gray12); 231 | 232 | &:hover .activeTheme { 233 | background: var(--grayA6); 234 | } 235 | 236 | &:active { 237 | transform: scale(0.96); 238 | 239 | .activeTheme { 240 | background: var(--grayA7); 241 | } 242 | } 243 | } 244 | } 245 | 246 | .activeTheme { 247 | background: var(--grayA5); 248 | border-radius: 9999px; 249 | height: 32px; 250 | width: 100%; 251 | top: 0; 252 | position: absolute; 253 | left: 0; 254 | } 255 | 256 | .arrow { 257 | color: var(--gray11); 258 | user-select: none; 259 | position: absolute; 260 | } 261 | } 262 | 263 | .header { 264 | position: absolute; 265 | left: 0; 266 | top: -64px; 267 | gap: 8px; 268 | background: var(--gray3); 269 | padding: 4px; 270 | display: flex; 271 | align-items: center; 272 | border-radius: 9999px; 273 | 274 | button { 275 | display: flex; 276 | align-items: center; 277 | justify-content: center; 278 | width: 28px; 279 | height: 28px; 280 | padding: 4px; 281 | border-radius: 9999px; 282 | color: var(--gray11); 283 | 284 | svg { 285 | width: 16px; 286 | height: 16px; 287 | } 288 | 289 | &[aria-selected='true'] { 290 | background: #ffffff; 291 | color: var(--gray12); 292 | box-shadow: 0px 2px 5px -2px rgb(0 0 0 / 15%), 0 1px 3px -1px rgb(0 0 0 / 20%); 293 | } 294 | } 295 | } 296 | 297 | .versionBadge { 298 | display: inline-flex; 299 | align-items: center; 300 | justify-content: center; 301 | color: var(--grayA11); 302 | background: var(--grayA3); 303 | padding: 4px 8px; 304 | border-radius: 4px; 305 | font-weight: 500; 306 | font-size: 14px; 307 | margin-bottom: 8px; 308 | 309 | @media (prefers-color-scheme: dark) { 310 | background: var(--grayA2); 311 | } 312 | } 313 | 314 | .codeBlock { 315 | margin-top: 72px; 316 | position: relative; 317 | } 318 | 319 | .footer { 320 | display: flex; 321 | align-items: center; 322 | gap: 4px; 323 | width: fit-content; 324 | margin: 32px auto; 325 | bottom: 16px; 326 | color: var(--gray11); 327 | font-size: 13px; 328 | z-index: 3; 329 | position: absolute; 330 | bottom: 0; 331 | 332 | a { 333 | display: inline-flex; 334 | align-items: center; 335 | gap: 4px; 336 | color: var(--gray12); 337 | font-weight: 500; 338 | border-radius: 9999px; 339 | padding: 4px; 340 | margin: 0 -2px; 341 | transition: background 150ms ease; 342 | 343 | &:hover, 344 | &:focus-visible { 345 | background: var(--grayA4); 346 | outline: 0; 347 | } 348 | } 349 | 350 | img { 351 | width: 20px; 352 | height: 20px; 353 | border: 1px solid var(--gray5); 354 | border-radius: 9999px; 355 | } 356 | } 357 | 358 | .line { 359 | height: 20px; 360 | width: 180px; 361 | margin: 64px auto; 362 | background-image: url('/line.svg'); 363 | filter: invert(1); 364 | mask-image: linear-gradient(90deg, transparent, #fff 4rem, #fff calc(100% - 4rem), transparent); 365 | 366 | @media (prefers-color-scheme: dark) { 367 | filter: unset; 368 | } 369 | } 370 | 371 | .line2 { 372 | height: 1px; 373 | width: 300px; 374 | background: var(--gray7); 375 | position: absolute; 376 | top: 0; 377 | mask-image: linear-gradient(90deg, transparent, #fff 4rem, #fff calc(100% - 4rem), transparent); 378 | } 379 | 380 | .line3 { 381 | height: 300px; 382 | width: calc(100% + 32px); 383 | position: absolute; 384 | top: -16px; 385 | left: -16px; 386 | 387 | border-radius: 16px 16px 0 0; 388 | --size: 1px; 389 | --gradient: linear-gradient(to top, var(--gray1), var(--gray7)); 390 | 391 | &::before { 392 | content: ''; 393 | position: absolute; 394 | inset: 0; 395 | border-radius: inherit; 396 | padding: var(--size); 397 | background: linear-gradient(to top, var(--gray1), var(--gray7)); 398 | mask: linear-gradient(black, black) content-box, linear-gradient(black, black); 399 | mask-composite: exclude; 400 | transform: translateZ(0); 401 | 402 | @media (prefers-color-scheme: dark) { 403 | mask: none; 404 | mask-composite: none; 405 | opacity: 0.2; 406 | backdrop-filter: blur(20px); 407 | } 408 | } 409 | } 410 | 411 | .raunoSignature, 412 | .pacoSignature { 413 | position: absolute; 414 | height: fit-content; 415 | color: var(--gray11); 416 | pointer-events: none; 417 | } 418 | 419 | .raunoSignature { 420 | width: 120px; 421 | stroke-dashoffset: 1; 422 | stroke-dasharray: 1; 423 | right: -48px; 424 | } 425 | 426 | .pacoSignature { 427 | width: 120px; 428 | stroke-dashoffset: 1; 429 | stroke-dasharray: 1; 430 | left: -8px; 431 | } 432 | 433 | .footerText { 434 | display: flex; 435 | display: flex; 436 | align-items: center; 437 | gap: 4px; 438 | opacity: 0; 439 | } 440 | 441 | .footer[data-animate='true'] { 442 | .raunoSignature path { 443 | animation: drawRaunoSignature 1.5s ease forwards 0.5s; 444 | } 445 | 446 | .pacoSignature path { 447 | animation: drawPacoSignature 0.8s linear forwards 0.5s; 448 | } 449 | 450 | .footerText { 451 | animation: showFooter 1s linear forwards 3s; 452 | } 453 | } 454 | 455 | @keyframes drawPacoSignature { 456 | 100% { 457 | stroke-dashoffset: 0; 458 | } 459 | } 460 | 461 | @keyframes drawRaunoSignature { 462 | 100% { 463 | stroke-dashoffset: 0; 464 | } 465 | } 466 | 467 | @keyframes showFooter { 468 | 100% { 469 | opacity: 1; 470 | } 471 | } 472 | 473 | @media (max-width: 640px) { 474 | .main { 475 | padding-top: 24px; 476 | padding-bottom: 120px; 477 | } 478 | 479 | .switcher { 480 | grid-template-columns: repeat(2, 100px); 481 | gap: 16px; 482 | 483 | .arrow { 484 | display: none; 485 | } 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": "." 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /website/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/inter-var-latin.woff2", 5 | "headers": [ 6 | { 7 | "key": "Cache-Control", 8 | "value": "public, max-age=31536000, immutable" 9 | } 10 | ] 11 | } 12 | ] 13 | } 14 | --------------------------------------------------------------------------------