diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx
index cf620736db..8bab78e084 100644
--- a/apps/web/content/docs/dev/configuration.mdx
+++ b/apps/web/content/docs/dev/configuration.mdx
@@ -98,6 +98,20 @@ Pass the component itself, not ``. Keep it lean: your Vite build
executes this file too, so a logo that imports half your UI makes every
regeneration pass slower.
+### View transitions
+
+Navigations crossfade and the logo morphs between layouts by default. Set
+`viewTransitions` to `false` to swap pages instantly instead:
+
+```ts title="src/vitnode.config.ts"
+export const vitNodeConfig = buildConfig({
+ // ...
+ viewTransitions: false,
+})
+```
+
+See [View transitions](/docs/ui/view-transitions) for what this changes.
+
### Enabled plugins
Register a plugin with its own factory:
diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx
index 6d351714c7..a22e1df3ad 100644
--- a/apps/web/content/docs/dev/sso/custom-adapter.mdx
+++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx
@@ -102,7 +102,7 @@ export const vitNodeApiConfig = buildApiConfig({
})
```
-A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is text-only until you give the adapter an [icon](/docs/dev/sso/icons).
+A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is a neutral, text-only button until you give the adapter an [icon and a brand color](/docs/dev/sso/icons).
---
@@ -121,7 +121,12 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and
type: "string",
},
icon: {
- description: "Brand mark for the login button: inline SVG markup or an image URL. See Provider Icons.",
+ description: "Brand mark for the login button: inline SVG markup or an image URL. See Provider Icons and Colors.",
+ required: false,
+ type: "string",
+ },
+ brandColor: {
+ description: "Hex color (#RGB or #RRGGBB) that fills the login button. The label turns white or near-black, whichever reads better. See Provider Icons and Colors.",
required: false,
type: "string",
},
@@ -148,7 +153,7 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and
}}
/>
-See [Provider Icons](/docs/dev/sso/icons) for the accepted `icon` formats, and for registering a React component instead.
+See [Provider Icons and Colors](/docs/dev/sso/icons) for the accepted `icon` and `brandColor` formats, and for registering a React component instead.
## Profile data
diff --git a/apps/web/content/docs/dev/sso/discord.mdx b/apps/web/content/docs/dev/sso/discord.mdx
index 8669f2b12b..b54a4c46cd 100644
--- a/apps/web/content/docs/dev/sso/discord.mdx
+++ b/apps/web/content/docs/dev/sso/discord.mdx
@@ -177,7 +177,7 @@ Then open `/login` and press **Discord**. You should see Discord's "connect to"
authorization prompt listing your username and email, get bounced back to
`/login/sso/discord?code=...&state=...`, and land on the front page signed in.
-{/* Image prompt: The VitNode /login page with the email and password fields above a divider reading "Or continue With" and an outline button labelled Discord beneath it, dark theme, 900x850. */}
+{/* Image prompt: The VitNode /login page with the email and password fields below a blurple button labelled "Continue with Discord" and a divider reading "Or continue with email", dark theme, 900x850. */}
diff --git a/apps/web/content/docs/dev/sso/icons.mdx b/apps/web/content/docs/dev/sso/icons.mdx
index d9d464e818..d477a88235 100644
--- a/apps/web/content/docs/dev/sso/icons.mdx
+++ b/apps/web/content/docs/dev/sso/icons.mdx
@@ -1,15 +1,26 @@
---
-title: Provider Icons
-description: Put a brand mark on your SSO login buttons - an inline SVG or image URL from the adapter, or a React component registered in the browser.
+title: Provider Icons and Colors
+description: Put a brand mark and a brand color on your SSO login buttons - an inline SVG or image URL and a hex color from the adapter, or a React component registered in the browser.
icon: Image
---
import { TypeTable } from 'fumadocs-ui/components/type-table'
-The built-in Google, Discord and Facebook adapters already carry their brand
-marks, so `/login` and `/register` render them without any configuration. A
-custom adapter starts out as text only - a **GitHub** button with no octocat -
-and there are two ways to give it a mark.
+import { ImgDocs } from '@/components/fumadocs/img'
+
+import loginSsoButtons from './icons/login-sso-buttons.png'
+
+Every SSO provider gets a full-width **Continue with _Provider_** button on
+`/login` and `/register`. The built-in Google, Discord and Facebook adapters
+already carry their brand marks and colors, so those buttons look right without
+any configuration. A custom adapter starts out as a neutral button with
+text only - a **GitHub** button with no octocat - and this page shows how to
+give it a mark and a color.
+
+
## From the adapter (SVG or image)
@@ -28,8 +39,10 @@ export const GitHubSSOApiPlugin = (): SSOApiPlugin => ({
```
Use `fill="currentColor"` and leave the width and height off: the mark then
-takes the button's text colour in both light and dark mode, and is sized to
-match the label.
+takes the button's text color in both light and dark mode - white on a
+[brand-colored button](#brand-color) - and is sized to match the label. A
+multicolor mark, such as Google's four-color G, keeps its own fills; give its
+adapter no brand color so it sits on the neutral button it was designed for.
An image works the same way:
@@ -102,7 +115,33 @@ copy `.svg` files into `dist`, so either copy it as part of your build or keep
the markup in a `.ts` constant, the way VitNode's own adapters do.
If you would rather have a component than a string, register it in the browser
-instead - that is the next section.
+instead - see [From the browser](#from-the-browser-react-component-or-jsx).
+
+## Brand color
+
+Add a `brandColor` to fill the button with the provider's color, the way the
+built-in Discord (`#5865F2`) and Facebook (`#0866FF`) adapters do:
+
+```ts title="apps/api/src/utils/sso/github.ts"
+export const GitHubSSOApiPlugin = (): SSOApiPlugin => ({
+ id: 'github',
+ name: 'GitHub',
+ icon: githubIcon,
+ // [!code ++]
+ brandColor: '#24292F',
+ // ... getUrl, fetchToken, fetchUser
+})
+```
+
+The value has to be a hex color - `#RGB` or `#RRGGBB`. The button picks its
+own text color: white when white text reaches a 4.5:1 contrast ratio on your
+color, near-black otherwise, so a yellow brand still gets a readable label. A
+`currentColor` icon follows that text color.
+
+Leave `brandColor` out and the button stays neutral, with a border. That is the
+right choice for providers whose guidelines ask for a neutral button, such as
+Google and Microsoft. A value that is not a hex color is ignored the same way,
+and in development the console names the provider.
## From the browser (React component or JSX)
@@ -133,6 +172,12 @@ needs a wrapper element.
diff --git a/apps/web/content/docs/ui/meta.json b/apps/web/content/docs/ui/meta.json
index 66f2ec853f..0ff7684c6b 100644
--- a/apps/web/content/docs/ui/meta.json
+++ b/apps/web/content/docs/ui/meta.json
@@ -11,6 +11,7 @@
"spacing",
"elevation",
"motion",
+ "view-transitions",
"icons",
"accessibility",
"---Forms---",
diff --git a/apps/web/content/docs/ui/view-transitions.mdx b/apps/web/content/docs/ui/view-transitions.mdx
new file mode 100644
index 0000000000..9a99757f83
--- /dev/null
+++ b/apps/web/content/docs/ui/view-transitions.mdx
@@ -0,0 +1,255 @@
+---
+title: View transitions
+description: Animate page changes, morph the logo between layouts and animate components with React's ViewTransition in VitNode, or turn view transitions off in vitnode.config.ts.
+icon: Layers2
+---
+
+VitNode uses the browser's View Transition API in two places. Every navigation
+to another page crossfades in 150ms, and the logo glides from the site header
+to the docs header. Inside a page, React's
+[``](https://react.dev/reference/react/ViewTransition) animates
+a component when it appears, leaves or changes.
+
+## Page transitions
+
+Click any link in the sidebar: the old page fades out while the new one fades
+in. Your router turns this on with `navigationViewTransition`:
+
+```tsx title="src/router.tsx"
+import { navigationViewTransition } from '@vitnode/core/tanstack/view-transitions'
+
+import { vitNodeConfig } from './vitnode.config'
+
+const router = createTanStackRouter({
+ defaultViewTransition: navigationViewTransition(
+ vitNodeConfig.viewTransitions,
+ ),
+ // ...
+})
+```
+
+The router starts the transition only after the next page has loaded, so the
+old page never freezes while data is fetched. Changes that only touch the
+query string, like paging a table or typing in a filter, skip the animation.
+
+The timing lives in `@vitnode/core/styles/view-transitions.css`, which your
+`styles.css` imports:
+
+```css title="src/styles.css"
+@import '@vitnode/core/styles/view-transitions.css';
+```
+
+## Morph the logo between layouts
+
+Go to the [home page](/) and click **Docs** in the header. The logo moves and
+resizes into the docs header instead of fading. Both logos carry the same
+`view-transition-name`, so the browser treats them as one element:
+
+```tsx
+import { LOGO_VIEW_TRANSITION_NAME } from '@vitnode/core/tanstack/view-transitions'
+
+;
+
+
+```
+
+The site header already does this for the logo you set in
+[`vitnode.config.ts`](/docs/dev/configuration#logo). Add the same style to a
+logo in your own layout to join in.
+
+
+ A `view-transition-name` must be unique among the visible elements on a page.
+ If two visible elements share a name, the browser skips the whole transition.
+ Put the name on one wrapper, not on a logo component that a footer renders
+ too.
+
+
+## Animate a component with ViewTransition
+
+
+
+Wrap the element in `` and change state inside
+`startTransition`. React animates the element with the class you pass to
+`enter` or `exit`:
+
+```tsx
+import { startTransition, ViewTransition } from 'react'
+
+{
+ isOpen && (
+
+
+
+ )
+}
+
+;
+```
+
+The class becomes a view transition class, so you style it with the
+`::view-transition-*` pseudo-elements:
+
+```css
+@keyframes vt-slide-in {
+ from {
+ opacity: 0;
+ translate: 0 0.5rem;
+ scale: 0.97;
+ }
+}
+
+@keyframes vt-slide-out {
+ to {
+ opacity: 0;
+ translate: 0 0.25rem;
+ scale: 0.98;
+ }
+}
+
+::view-transition-new(.vt-slide-up) {
+ animation: vt-slide-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both;
+}
+
+::view-transition-old(.vt-slide-down) {
+ animation: vt-slide-out 150ms cubic-bezier(0.4, 0, 1, 1) both;
+}
+```
+
+The exit is shorter and travels less than the enter: once something is
+dismissed, it should get out of the way. A plain `setState` outside
+`startTransition` updates instantly, with no animation.
+
+## Shared element transitions
+
+
+
+Give two `` boundaries the same `name`. When one unmounts and
+the other mounts in the same transition, React morphs the first into the
+second:
+
+```tsx
+
+
+
+```
+
+Build each name from a stable id, so a list item and its detail view match.
+The same uniqueness rule as the logo applies.
+
+The browser stretches both snapshots to the morphing box by default, which
+squashes text when the shape changes. Keep them at their natural size and let
+the box clip them instead:
+
+```css
+::view-transition-group(.vt-morph) {
+ overflow: clip;
+ border-radius: var(--radius-xl);
+ animation-duration: 250ms;
+ animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+::view-transition-old(.vt-morph),
+::view-transition-new(.vt-morph) {
+ height: 100%;
+ object-fit: none;
+ object-position: left top;
+}
+```
+
+A shared element is already on screen, so it moves with an ease-in-out curve
+rather than the ease-out used for things that appear. When the icon or title
+sits in a different spot in each view, give it its own `name` too
+(`${name}-mark`, `${name}-title`). It then travels on its own path instead of
+showing up twice while the boxes crossfade.
+
+## Direction with transition types
+
+
+
+Call `addTransitionType` inside `startTransition` to tag the update, then map
+each type to a class. Here **Next** slides in from the right and **Back** from
+the left:
+
+```tsx
+import { addTransitionType, startTransition, ViewTransition } from 'react'
+
+const go = (direction: 'back' | 'forward') => {
+ startTransition(() => {
+ addTransitionType(direction)
+ setIndex((index) => index + (direction === 'forward' ? 1 : -1))
+ })
+}
+
+;
+
+
+```
+
+Changing `key` makes React treat each step as a new element, so the old step
+exits while the new one enters. Keep the card's border and background outside
+the ``: snapshots are drawn above the page and ignore
+`overflow: hidden`, so only content that stays inside the padding should move.
+
+
+ TanStack Router renders a route change as a synchronous update, and React
+ only runs `` animations for updates inside `startTransition`.
+ That is why navigations use the router's `defaultViewTransition` and
+ `` covers changes inside a page.
+
+
+## Turn view transitions off
+
+Set `viewTransitions` to `false` in your shared config:
+
+```ts title="src/vitnode.config.ts"
+export const vitNodeConfig = buildConfig({
+ // ...
+ viewTransitions: false,
+})
+```
+
+Navigations then swap pages instantly and the logo no longer morphs. The
+option is `true` by default. React `` boundaries in your own
+components still animate, because they don't go through the router.
+
+## Reduced motion
+
+When the system asks for reduced motion, the router skips page transitions and
+`view-transitions.css` turns off every view transition animation, including
+your own `` classes. Browsers without the View Transition API
+just swap the content instantly.
+
+## Related
+
+
+
+
+
+
diff --git a/apps/web/src/docs/docs.css b/apps/web/src/docs/docs.css
index f97f516506..ff46dfcc56 100644
--- a/apps/web/src/docs/docs.css
+++ b/apps/web/src/docs/docs.css
@@ -17,3 +17,87 @@
:root:not(.dark) #fd-spacious-layout .prose {
--tw-prose-body: oklch(0.373 0.003 264);
}
+
+@keyframes vt-enter {
+ from {
+ opacity: 0;
+ translate: var(--vt-from-x, 0) var(--vt-from-y, 0);
+ scale: var(--vt-from-scale, 1);
+ }
+}
+
+@keyframes vt-exit {
+ to {
+ opacity: 0;
+ translate: var(--vt-to-x, 0) var(--vt-to-y, 0);
+ scale: var(--vt-to-scale, 1);
+ }
+}
+
+::view-transition-new(.vt-slide-up),
+::view-transition-new(.vt-fade-in),
+::view-transition-new(.vt-from-left),
+::view-transition-new(.vt-from-right) {
+ animation: vt-enter 200ms cubic-bezier(0.23, 1, 0.32, 1) both;
+}
+
+::view-transition-old(.vt-slide-down),
+::view-transition-old(.vt-fade-out) {
+ animation: vt-exit 150ms cubic-bezier(0.4, 0, 1, 1) both;
+}
+
+::view-transition-old(.vt-to-left),
+::view-transition-old(.vt-to-right) {
+ animation: vt-exit 120ms cubic-bezier(0.23, 1, 0.32, 1) both;
+}
+
+::view-transition-new(.vt-slide-up) {
+ --vt-from-y: 0.5rem;
+ --vt-from-scale: 0.97;
+}
+
+::view-transition-old(.vt-slide-down) {
+ --vt-to-y: 0.25rem;
+ --vt-to-scale: 0.98;
+}
+
+::view-transition-new(.vt-from-right) {
+ --vt-from-x: 1rem;
+ animation-delay: 40ms;
+}
+
+::view-transition-new(.vt-from-left) {
+ --vt-from-x: -1rem;
+ animation-delay: 40ms;
+}
+
+::view-transition-old(.vt-to-left) {
+ --vt-to-x: -1rem;
+}
+
+::view-transition-old(.vt-to-right) {
+ --vt-to-x: 1rem;
+}
+
+::view-transition-group(.vt-morph) {
+ overflow: clip;
+ border-radius: var(--radius-xl);
+ animation-duration: 250ms;
+ animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+::view-transition-old(.vt-morph),
+::view-transition-new(.vt-morph) {
+ height: 100%;
+ object-fit: none;
+ object-position: left top;
+ animation-duration: 250ms;
+ animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+::view-transition-group(.vt-morph-part),
+::view-transition-old(.vt-morph-part),
+::view-transition-new(.vt-morph-part) {
+ animation-duration: 250ms;
+ animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
diff --git a/apps/web/src/docs/examples/view-transition-enter-exit.tsx b/apps/web/src/docs/examples/view-transition-enter-exit.tsx
new file mode 100644
index 0000000000..bc5ca80773
--- /dev/null
+++ b/apps/web/src/docs/examples/view-transition-enter-exit.tsx
@@ -0,0 +1,34 @@
+import { Button } from '@vitnode/core/components/ui/button'
+import { BellIcon } from 'lucide-react'
+import React, { startTransition, ViewTransition } from 'react'
+
+export default function ViewTransitionEnterExit() {
+ const [isOpen, setIsOpen] = React.useState(true)
+
+ return (
+
@@ -71,8 +106,8 @@ export const SSOButtonsContent = ({
/** The row's shape while the deployment configuration is still in flight. */
export const SSOButtonsSkeleton = () => (
-
-
-
+
+
+
);
diff --git a/packages/vitnode/src/views/auth/sso/providers.test.ts b/packages/vitnode/src/views/auth/sso/providers.test.ts
index a90407d7cd..c353f84ea4 100644
--- a/packages/vitnode/src/views/auth/sso/providers.test.ts
+++ b/packages/vitnode/src/views/auth/sso/providers.test.ts
@@ -67,6 +67,24 @@ describe("normalising the SSO provider list", () => {
]);
});
+ it("keeps a hex brand color and drops anything else", () => {
+ expect(
+ normalizeSSOProviders([
+ { brandColor: "#5865F2", id: "discord", name: "Discord" },
+ { brandColor: " #FFF ", id: "white", name: "White" },
+ { brandColor: "red", id: "named", name: "Named" },
+ { brandColor: "#5865F2; background: url(x)", id: "css", name: "CSS" },
+ { brandColor: 42, id: "number", name: "Number" },
+ ]),
+ ).toEqual([
+ { brandColor: "#5865f2", id: "discord", name: "Discord" },
+ { brandColor: "#fff", id: "white", name: "White" },
+ { id: "named", name: "Named" },
+ { id: "css", name: "CSS" },
+ { id: "number", name: "Number" },
+ ]);
+ });
+
it("keeps the first of two providers sharing an id", () => {
// React keys the row by id, so a duplicate is a warning plus a button that
// cannot be told apart from the one above it.
diff --git a/packages/vitnode/src/views/auth/sso/providers.ts b/packages/vitnode/src/views/auth/sso/providers.ts
index 39610f2287..0b9973ad57 100644
--- a/packages/vitnode/src/views/auth/sso/providers.ts
+++ b/packages/vitnode/src/views/auth/sso/providers.ts
@@ -1,14 +1,17 @@
import type { SSOIconSource } from "./icon";
+import { ssoBrandColor } from "./brand";
import { ssoIconSource } from "./icon";
export interface SSOProvider {
+ brandColor?: string;
icon?: SSOIconSource;
id: string;
name: string;
}
interface UnverifiedProvider {
+ brandColor?: unknown;
icon?: unknown;
id: string;
name: string;
@@ -23,22 +26,45 @@ const isProvider = (value: unknown): value is UnverifiedProvider =>
const warned = new Set();
-const warnAboutIcon = (providerId: string) => {
- if (process.env.NODE_ENV !== "development" || warned.has(providerId)) return;
+const warnOnce = (key: string, message: string) => {
+ if (process.env.NODE_ENV !== "development" || warned.has(key)) return;
- warned.add(providerId);
+ warned.add(key);
// oxlint-disable-next-line no-console
- console.warn(
- `[vitnode] the SSO provider "${providerId}" sent an icon its button cannot render, so it renders without one. An icon has to be a single