next.js / packages /next /src /next-devtools /dev-overlay /components /devtools-indicator /next-logo.tsx
| import { useRef, useState } from 'react' | |
| import { useUpdateAnimation } from './hooks/use-update-animation' | |
| import { useMeasureWidth } from './hooks/use-measure-width' | |
| import { Cross } from '../../icons/cross' | |
| import { Warning } from '../../icons/warning' | |
| import { css } from '../../utils/css' | |
| import { useDevOverlayContext } from '../../../dev-overlay.browser' | |
| import { useRenderErrorContext } from '../../dev-overlay' | |
| import { useDelayedRender } from '../../hooks/use-delayed-render' | |
| import { | |
| ACTION_ERROR_OVERLAY_CLOSE, | |
| ACTION_ERROR_OVERLAY_OPEN, | |
| } from '../../shared' | |
| import { usePanelRouterContext } from '../../menu/context' | |
| import { BASE_LOGO_SIZE } from '../../utils/indicator-metrics' | |
| import { StatusIndicator, Status, getCurrentStatus } from './status-indicator' | |
| const SHORT_DURATION_MS = 150 | |
| export function NextLogo({ | |
| onTriggerClick, | |
| ...buttonProps | |
| }: { onTriggerClick: () => void } & React.ComponentProps<'button'>) { | |
| const { state, dispatch } = useDevOverlayContext() | |
| const { totalErrorCount } = useRenderErrorContext() | |
| const SIZE = BASE_LOGO_SIZE / state.scale | |
| const { panel, triggerRef, setPanel } = usePanelRouterContext() | |
| const isMenuOpen = panel === 'panel-selector' | |
| const hasError = totalErrorCount > 0 | |
| const [isErrorExpanded, setIsErrorExpanded] = useState(hasError) | |
| const [previousHasError, setPreviousHasError] = useState(hasError) | |
| if (previousHasError !== hasError) { | |
| setPreviousHasError(hasError) | |
| // Reset the expanded state when the error state changes | |
| setIsErrorExpanded(hasError) | |
| } | |
| const [dismissed, setDismissed] = useState(false) | |
| const newErrorDetected = useUpdateAnimation( | |
| totalErrorCount, | |
| SHORT_DURATION_MS | |
| ) | |
| // Cache indicator state management | |
| const isCacheFilling = state.cacheIndicator === 'filling' | |
| const isCacheBypassing = state.cacheIndicator === 'bypass' | |
| // Determine if we should show any status (excluding cache bypass, which renders like error badge) | |
| const shouldShowStatus = | |
| state.buildingIndicator || state.renderingIndicator || isCacheFilling | |
| // Delay showing for 400ms to catch fast operations, | |
| // and keep visible for minimum time (longer for warnings) | |
| const { rendered: showStatusIndicator } = useDelayedRender(shouldShowStatus, { | |
| enterDelay: 400, | |
| exitDelay: 500, | |
| }) | |
| const ref = useRef<HTMLDivElement | null>(null) | |
| const measuredWidth = useMeasureWidth(ref) | |
| // Get the current status from the state | |
| const currentStatus = getCurrentStatus( | |
| state.buildingIndicator, | |
| state.renderingIndicator, | |
| state.cacheIndicator | |
| ) | |
| const displayStatus = showStatusIndicator ? currentStatus : Status.None | |
| const isExpanded = | |
| isErrorExpanded || | |
| isCacheBypassing || | |
| showStatusIndicator || | |
| state.disableDevIndicator | |
| const width = measuredWidth === 0 ? 'auto' : measuredWidth | |
| return ( | |
| <div | |
| data-next-badge-root | |
| style={ | |
| { | |
| '--size': `${SIZE}px`, | |
| '--duration-short': `${SHORT_DURATION_MS}ms`, | |
| // if the indicator is disabled, hide the badge | |
| // also allow the "disabled" state be dismissed, as long as there are no build errors | |
| display: | |
| state.disableDevIndicator && (!hasError || dismissed) | |
| ? 'none' | |
| : 'block', | |
| } as React.CSSProperties | |
| } | |
| > | |
| {/* Styles */} | |
| <style> | |
| {css` | |
| [data-next-badge-root] { | |
| --timing: cubic-bezier(0.23, 0.88, 0.26, 0.92); | |
| --duration-long: 250ms; | |
| --color-outer-border: #171717; | |
| --color-inner-border: hsla(0, 0%, 100%, 0.14); | |
| --color-hover-alpha-subtle: hsla(0, 0%, 100%, 0.13); | |
| --color-hover-alpha-error: hsla(0, 0%, 100%, 0.2); | |
| --color-hover-alpha-error-2: hsla(0, 0%, 100%, 0.25); | |
| --mark-size: calc(var(--size) - var(--size-2) * 2); | |
| --focus-color: var(--color-blue-800); | |
| --focus-ring: 2px solid var(--focus-color); | |
| &:has([data-next-badge][data-error='true']) { | |
| --focus-color: #fff; | |
| } | |
| } | |
| [data-disabled-icon] { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding-right: 4px; | |
| } | |
| [data-next-badge] { | |
| width: var(--size); | |
| height: var(--size); | |
| display: flex; | |
| align-items: center; | |
| position: relative; | |
| background: rgba(0, 0, 0, 0.8); | |
| box-shadow: | |
| 0 0 0 1px var(--color-outer-border), | |
| inset 0 0 0 1px var(--color-inner-border), | |
| 0px 16px 32px -8px rgba(0, 0, 0, 0.24); | |
| backdrop-filter: blur(48px); | |
| border-radius: var(--rounded-full); | |
| user-select: none; | |
| cursor: pointer; | |
| scale: 1; | |
| overflow: hidden; | |
| will-change: scale, box-shadow, width, background; | |
| transition: | |
| scale var(--duration-short) var(--timing), | |
| width var(--duration-long) var(--timing), | |
| box-shadow var(--duration-long) var(--timing), | |
| background var(--duration-short) ease; | |
| &:active[data-error='false'] { | |
| scale: 0.95; | |
| } | |
| &[data-animate='true']:not(:hover) { | |
| scale: 1.02; | |
| } | |
| &[data-error='false']:has([data-next-mark]:focus-visible) { | |
| outline: var(--focus-ring); | |
| outline-offset: 3px; | |
| } | |
| &[data-error='true'] { | |
| background: #ca2a30; | |
| --color-inner-border: #e5484d; | |
| [data-next-mark] { | |
| background: var(--color-hover-alpha-error); | |
| outline-offset: 0px; | |
| &:focus-visible { | |
| outline: var(--focus-ring); | |
| outline-offset: -1px; | |
| } | |
| &:hover { | |
| background: var(--color-hover-alpha-error-2); | |
| } | |
| } | |
| } | |
| &[data-cache-bypassing='true']:not([data-error='true']) { | |
| background: rgba(217, 119, 6, 0.95); | |
| --color-inner-border: rgba(245, 158, 11, 0.9); | |
| [data-issues-open] { | |
| color: white; | |
| } | |
| } | |
| &[data-error-expanded='false'][data-error='true'] ~ [data-dot] { | |
| scale: 1; | |
| } | |
| > div { | |
| display: flex; | |
| } | |
| } | |
| [data-issues-collapse]:focus-visible { | |
| outline: var(--focus-ring); | |
| } | |
| [data-issues]:has([data-issues-open]:focus-visible) { | |
| outline: var(--focus-ring); | |
| outline-offset: -1px; | |
| } | |
| [data-dot] { | |
| content: ''; | |
| width: var(--size-8); | |
| height: var(--size-8); | |
| background: #fff; | |
| box-shadow: 0 0 0 1px var(--color-outer-border); | |
| border-radius: 50%; | |
| position: absolute; | |
| top: 2px; | |
| right: 0px; | |
| scale: 0; | |
| pointer-events: none; | |
| transition: scale 200ms var(--timing); | |
| transition-delay: var(--duration-short); | |
| } | |
| [data-issues] { | |
| --padding-left: 8px; | |
| display: flex; | |
| gap: 2px; | |
| align-items: center; | |
| padding-left: 8px; | |
| padding-right: 8px; | |
| height: var(--size-32); | |
| margin-right: 2px; | |
| border-radius: var(--rounded-full); | |
| transition: background var(--duration-short) ease; | |
| &:has([data-issues-open]:hover) { | |
| background: var(--color-hover-alpha-error); | |
| } | |
| &:has([data-issues-collapse]) { | |
| padding-right: calc(var(--padding-left) / 2); | |
| } | |
| [data-cross] { | |
| translate: 0px -1px; | |
| } | |
| } | |
| [data-issues-open] { | |
| font-size: var(--size-13); | |
| color: white; | |
| width: fit-content; | |
| height: 100%; | |
| display: flex; | |
| gap: 2px; | |
| align-items: center; | |
| margin: 0; | |
| line-height: var(--size-36); | |
| font-weight: 500; | |
| z-index: 2; | |
| white-space: nowrap; | |
| &:focus-visible { | |
| outline: 0; | |
| } | |
| } | |
| [data-issues-collapse] { | |
| width: var(--size-24); | |
| height: var(--size-24); | |
| border-radius: var(--rounded-full); | |
| transition: background var(--duration-short) ease; | |
| &:hover { | |
| background: var(--color-hover-alpha-error); | |
| } | |
| } | |
| [data-cross] { | |
| color: #fff; | |
| width: var(--size-12); | |
| height: var(--size-12); | |
| } | |
| [data-next-mark] { | |
| width: var(--mark-size); | |
| height: var(--mark-size); | |
| margin: 0 2px; | |
| display: flex; | |
| align-items: center; | |
| border-radius: var(--rounded-full); | |
| transition: background var(--duration-long) var(--timing); | |
| &:focus-visible { | |
| outline: 0; | |
| } | |
| &:hover { | |
| background: var(--color-hover-alpha-subtle); | |
| } | |
| svg { | |
| flex-shrink: 0; | |
| width: var(--size-40); | |
| height: var(--size-40); | |
| } | |
| } | |
| [data-issues-count-animation] { | |
| display: grid; | |
| place-items: center center; | |
| font-variant-numeric: tabular-nums; | |
| &[data-animate='false'] { | |
| [data-issues-count-exit], | |
| [data-issues-count-enter] { | |
| animation-duration: 0ms; | |
| } | |
| } | |
| > * { | |
| grid-area: 1 / 1; | |
| } | |
| [data-issues-count-exit] { | |
| animation: fadeOut 300ms var(--timing) forwards; | |
| } | |
| [data-issues-count-enter] { | |
| animation: fadeIn 300ms var(--timing) forwards; | |
| } | |
| } | |
| [data-issues-count-plural] { | |
| display: inline-block; | |
| &[data-animate='true'] { | |
| animation: fadeIn 300ms var(--timing) forwards; | |
| } | |
| } | |
| .paused { | |
| stroke-dashoffset: 0; | |
| } | |
| @keyframes fadeIn { | |
| 0% { | |
| opacity: 0; | |
| filter: blur(2px); | |
| transform: translateY(8px); | |
| } | |
| 100% { | |
| opacity: 1; | |
| filter: blur(0px); | |
| transform: translateY(0); | |
| } | |
| } | |
| @keyframes fadeOut { | |
| 0% { | |
| opacity: 1; | |
| filter: blur(0px); | |
| transform: translateY(0); | |
| } | |
| 100% { | |
| opacity: 0; | |
| transform: translateY(-12px); | |
| filter: blur(2px); | |
| } | |
| } | |
| @media (prefers-reduced-motion) { | |
| [data-issues-count-exit], | |
| [data-issues-count-enter], | |
| [data-issues-count-plural] { | |
| animation-duration: 0ms ; | |
| } | |
| } | |
| `} | |
| </style> | |
| <div | |
| data-next-badge | |
| data-error={hasError} | |
| data-error-expanded={isExpanded} | |
| data-status={hasError || isCacheBypassing ? Status.None : currentStatus} | |
| data-cache-bypassing={isCacheBypassing} | |
| data-animate={newErrorDetected} | |
| style={{ width }} | |
| > | |
| <div ref={ref}> | |
| {/* Children */} | |
| {!state.disableDevIndicator && ( | |
| <button | |
| id="next-logo" | |
| ref={triggerRef} | |
| data-next-mark | |
| onClick={onTriggerClick} | |
| disabled={state.disableDevIndicator} | |
| aria-haspopup="menu" | |
| aria-expanded={isMenuOpen} | |
| aria-controls="nextjs-dev-tools-menu" | |
| aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`} | |
| data-nextjs-dev-tools-button | |
| style={{ | |
| display: | |
| showStatusIndicator && !hasError && !isCacheBypassing | |
| ? 'none' | |
| : 'flex', | |
| }} | |
| {...buttonProps} | |
| > | |
| <NextMark /> | |
| </button> | |
| )} | |
| {isExpanded && ( | |
| <> | |
| {/* Error badge has priority over cache indicator */} | |
| {(isErrorExpanded || state.disableDevIndicator) && ( | |
| <div data-issues> | |
| <button | |
| data-issues-open | |
| aria-label="Open issues overlay" | |
| onClick={() => { | |
| if (state.isErrorOverlayOpen) { | |
| dispatch({ | |
| type: ACTION_ERROR_OVERLAY_CLOSE, | |
| }) | |
| return | |
| } | |
| dispatch({ type: ACTION_ERROR_OVERLAY_OPEN }) | |
| setPanel(null) | |
| }} | |
| > | |
| {state.disableDevIndicator && ( | |
| <div data-disabled-icon> | |
| <Warning /> | |
| </div> | |
| )} | |
| <AnimateCount | |
| // Used the key to force a re-render when the count changes. | |
| key={totalErrorCount} | |
| animate={newErrorDetected} | |
| data-issues-count-animation | |
| > | |
| {totalErrorCount} | |
| </AnimateCount>{' '} | |
| <div> | |
| Issue | |
| {totalErrorCount > 1 && ( | |
| <span | |
| aria-hidden | |
| data-issues-count-plural | |
| // This only needs to animate once the count changes from 1 -> 2, | |
| // otherwise it should stay static between re-renders. | |
| data-animate={ | |
| newErrorDetected && totalErrorCount === 2 | |
| } | |
| > | |
| s | |
| </span> | |
| )} | |
| </div> | |
| </button> | |
| {!state.buildError && ( | |
| <button | |
| data-issues-collapse | |
| aria-label="Collapse issues badge" | |
| onClick={() => { | |
| if (state.disableDevIndicator) { | |
| setDismissed(true) | |
| } else { | |
| setIsErrorExpanded(false) | |
| } | |
| // Move focus to the trigger to prevent having it stuck on this element | |
| triggerRef.current?.focus() | |
| }} | |
| > | |
| <Cross data-cross /> | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* Cache bypass badge shown when cache is being bypassed */} | |
| {isCacheBypassing && !hasError && !state.disableDevIndicator && ( | |
| <CacheBypassBadge | |
| onTriggerClick={onTriggerClick} | |
| triggerRef={triggerRef} | |
| /> | |
| )} | |
| {/* Status indicator shown when no errors and no cache bypass */} | |
| {showStatusIndicator && | |
| !hasError && | |
| !isCacheBypassing && | |
| !state.disableDevIndicator && ( | |
| <StatusIndicator | |
| status={displayStatus} | |
| onClick={onTriggerClick} | |
| /> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| <div aria-hidden data-dot /> | |
| </div> | |
| ) | |
| } | |
| function AnimateCount({ | |
| children: count, | |
| animate = true, | |
| ...props | |
| }: { | |
| children: number | |
| animate: boolean | |
| }) { | |
| return ( | |
| <div {...props} data-animate={animate}> | |
| <div aria-hidden data-issues-count-exit> | |
| {count - 1} | |
| </div> | |
| <div data-issues-count data-issues-count-enter> | |
| {count} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function CacheBypassBadge({ | |
| onTriggerClick, | |
| triggerRef, | |
| }: { | |
| onTriggerClick: () => void | |
| triggerRef: React.RefObject<HTMLButtonElement | null> | |
| }) { | |
| const [dismissed, setDismissed] = useState(false) | |
| if (dismissed) { | |
| return null | |
| } | |
| return ( | |
| <div data-issues data-cache-bypass-badge> | |
| <button | |
| data-issues-open | |
| data-nextjs-dev-tools-button | |
| aria-label="Open Next.js Dev Tools" | |
| onClick={onTriggerClick} | |
| > | |
| Cache disabled | |
| </button> | |
| <button | |
| data-issues-collapse | |
| aria-label="Collapse cache bypass badge" | |
| onClick={() => { | |
| setDismissed(true) | |
| // Move focus to the trigger to prevent having it stuck on this element | |
| triggerRef.current?.focus() | |
| }} | |
| > | |
| <Cross data-cross /> | |
| </button> | |
| </div> | |
| ) | |
| } | |
| function NextMark() { | |
| return ( | |
| <svg width="40" height="40" viewBox="0 0 40 40" fill="none"> | |
| <g transform="translate(8.5, 13)"> | |
| <path | |
| className="paused" | |
| d="M13.3 15.2 L2.34 1 V12.6" | |
| fill="none" | |
| stroke="url(#next_logo_paint0_linear_1357_10853)" | |
| strokeWidth="1.86" | |
| mask="url(#next_logo_mask0)" | |
| strokeDasharray="29.6" | |
| strokeDashoffset="29.6" | |
| /> | |
| <path | |
| className="paused" | |
| d="M11.825 1.5 V13.1" | |
| strokeWidth="1.86" | |
| stroke="url(#next_logo_paint1_linear_1357_10853)" | |
| strokeDasharray="11.6" | |
| strokeDashoffset="11.6" | |
| /> | |
| </g> | |
| <defs> | |
| <linearGradient | |
| id="next_logo_paint0_linear_1357_10853" | |
| x1="9.95555" | |
| y1="11.1226" | |
| x2="15.4778" | |
| y2="17.9671" | |
| gradientUnits="userSpaceOnUse" | |
| > | |
| <stop stopColor="white" /> | |
| <stop offset="0.604072" stopColor="white" stopOpacity="0" /> | |
| <stop offset="1" stopColor="white" stopOpacity="0" /> | |
| </linearGradient> | |
| <linearGradient | |
| id="next_logo_paint1_linear_1357_10853" | |
| x1="11.8222" | |
| y1="1.40039" | |
| x2="11.791" | |
| y2="9.62542" | |
| gradientUnits="userSpaceOnUse" | |
| > | |
| <stop stopColor="white" /> | |
| <stop offset="1" stopColor="white" stopOpacity="0" /> | |
| </linearGradient> | |
| <mask id="next_logo_mask0"> | |
| <rect width="100%" height="100%" fill="white" /> | |
| <rect width="5" height="1.5" fill="black" /> | |
| </mask> | |
| </defs> | |
| </svg> | |
| ) | |
| } | |