+
+
+
+
+ Action
+
+
+
+
+ Link
+
+
+
+ {showRightFade && }
+
+
+ `}
+ language="jsx"
+/>
diff --git a/src/hooks/useScrollFadeStyles.ts b/src/hooks/useScrollFadeStyles.ts
new file mode 100644
index 0000000000..37a5df5e5f
--- /dev/null
+++ b/src/hooks/useScrollFadeStyles.ts
@@ -0,0 +1,70 @@
+import React, { useEffect, useState, useRef } from "react";
+
+/**
+ * The `useScrollFadeStyles` hook manages the visibility of a right-side fade effect
+ * based on horizontal scrolling. It tracks scroll events and shows the fade effect
+ * when scrolling to the left and more content is available to the right, hiding it
+ * when scrolling right or when at the right edge of the scrollable container.
+ */
+export default function useScrollFadeStyles() {
+ const [showRightFade, setShowRightFade] = useState(false);
+ const [lastScrollLeft, setLastScrollLeft] = useState(0);
+ const scrollableRef = useRef(null);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ if (scrollableRef.current) {
+ const { scrollLeft, scrollWidth, clientWidth } = scrollableRef.current;
+
+ // If content fits exactly or only has a small amount of extra space, hide the fade
+ if (
+ scrollWidth <= clientWidth ||
+ scrollLeft + clientWidth >= scrollWidth
+ ) {
+ setShowRightFade(false);
+ return;
+ }
+
+ // Determine if we are scrolling to the left or right
+ const isScrollingRight = scrollLeft > lastScrollLeft;
+ setLastScrollLeft(scrollLeft); // Update last scroll position
+
+ // Show right fade only if scrolling to the left and not at the end
+ const atRightEdge = scrollLeft + clientWidth >= scrollWidth - 1; // Buffer to prevent flickering
+ if (!isScrollingRight && !atRightEdge) {
+ setShowRightFade(true);
+ } else {
+ setShowRightFade(false); // Hide when scrolling right or when near the right edge
+ }
+ }
+ };
+
+ const refCurrent = scrollableRef.current;
+ if (refCurrent) {
+ refCurrent.addEventListener("scroll", handleScroll);
+
+ // Initial check to set the right fade effect based on the initial scroll state
+ const { scrollWidth, clientWidth, scrollLeft } = refCurrent;
+ const atRightEdge = scrollLeft + clientWidth >= scrollWidth - 1; // Buffer to prevent flickering
+
+ // Set the right fade effect if content is scrollable and not at the right edge
+ if (scrollWidth > clientWidth && !atRightEdge) {
+ setShowRightFade(true);
+ } else {
+ setShowRightFade(false);
+ }
+ }
+
+ // Cleanup event listener on component unmount
+ return () => {
+ if (refCurrent) {
+ refCurrent.removeEventListener("scroll", handleScroll);
+ }
+ };
+ }, [lastScrollLeft]); // Only depend on `lastScrollLeft`
+
+ return {
+ scrollableRef,
+ showRightFade,
+ };
+}
diff --git a/src/index.ts b/src/index.ts
index 15a007cf13..292a520ed8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -167,6 +167,12 @@ export type {
ListItemsData,
MenuProps,
} from "./components/Menu/Menu";
+
+export type {
+ default as SubNav,
+ SubNavButton,
+ SubNavLink,
+} from "./components/SubNav/SubNav";
export { ModalTrigger, useModal } from "./components/Modal/Modal";
export type {
BaseModalProps,
@@ -304,6 +310,7 @@ export { default as useMultiSelect } from "./hooks/useMultiSelect";
export { default as useNYPLBreakpoints } from "./hooks/useNYPLBreakpoints";
export { default as useNYPLTheme } from "./hooks/useNYPLTheme";
export { default as useWindowSize } from "./hooks/useWindowSize";
+export { default as useScrollFadeStyles } from "./hooks/useScrollFadeStyles";
export { default as VideoPlayer } from "./components/VideoPlayer/VideoPlayer";
export type {
VideoPlayerAspectRatios,
diff --git a/src/theme/components/subnav.ts b/src/theme/components/subnav.ts
new file mode 100644
index 0000000000..926487ef13
--- /dev/null
+++ b/src/theme/components/subnav.ts
@@ -0,0 +1,207 @@
+import { createMultiStyleConfigHelpers } from "@chakra-ui/styled-system";
+import { StyleFunctionProps } from "@chakra-ui/system";
+
+// This function creates a set of function that helps us
+// create multipart component styles.
+const {
+ defineMultiStyleConfig: subNavChildrenDefineMultiStyleConfig,
+ definePartsStyle: subNavChildrenDefinePartsStyle,
+} = createMultiStyleConfigHelpers(["outLine"]);
+
+const {
+ defineMultiStyleConfig: subNavDefineMultiStyleConfig,
+ definePartsStyle: subNavDefinePartsStyle,
+} = createMultiStyleConfigHelpers([
+ "base",
+ "container",
+ "primaryActions",
+ "selectedItem",
+ "secondaryActions",
+]);
+
+interface SubNavStyleProps extends StyleFunctionProps {
+ backgroundColor: string;
+ highlightColor: string;
+}
+
+interface SubNavChildrenStyleProps extends StyleFunctionProps {
+ isOutlined: boolean;
+}
+
+const commonStyles = () => ({
+ alignItems: "center",
+ display: "inline-flex",
+ fontSize: "desktop.button.large",
+ fontWeight: "regular",
+ gap: "xs",
+ height: { base: "44px", md: "unset" },
+ lineHeight: "1.5",
+ position: "relative",
+ px: "s",
+ py: "xxs",
+ textDecoration: "none !important",
+ transition: "background-color 0.2s, color 0.2s",
+});
+
+const ulStyles = {
+ p: { base: "s", md: "xs" },
+ gap: "xs",
+ li: {
+ marginEnd: "unset",
+ },
+ margin: "0",
+};
+
+const SubNav = subNavDefineMultiStyleConfig({
+ baseStyle: subNavDefinePartsStyle(
+ ({ backgroundColor, highlightColor }: SubNavStyleProps) => {
+ const defaultLabelColor = "ui.typography.body";
+ const highlightOrDefaultColor = highlightColor
+ ? highlightColor
+ : `${defaultLabelColor}`;
+ const highlightOrLinkColor = highlightColor
+ ? highlightColor
+ : "ui.link.primary";
+ const highlightOrBorderColor = highlightColor
+ ? highlightColor
+ : "ui.border.default";
+ const finalBackgroundColor = backgroundColor
+ ? backgroundColor
+ : "ui.link.primary-05";
+ const primaryActionsStyles = {
+ ...commonStyles(),
+ svg: {
+ fill: defaultLabelColor,
+ margin: { base: "0", md: null },
+ _dark: {
+ fill: "ui.white",
+ },
+ },
+ _hover: {
+ backgroundColor: finalBackgroundColor,
+ color: highlightOrDefaultColor,
+ svg: {
+ fill: highlightOrDefaultColor,
+ _dark: {
+ fill:
+ backgroundColor !== undefined
+ ? `${backgroundColor} `
+ : "ui.white",
+ },
+ },
+ },
+ };
+ const secondaryActionsStyles = {
+ ...commonStyles(),
+ color: highlightOrLinkColor,
+ svg: {
+ fill: highlightOrLinkColor,
+ margin: { base: "0", md: null },
+ _dark: {
+ fill: backgroundColor
+ ? `${backgroundColor} !important`
+ : "dark.ui.link.primary-05 !important",
+ },
+ },
+ _hover: {
+ background: finalBackgroundColor,
+ color: highlightOrLinkColor,
+ svg: {
+ fill: highlightOrLinkColor,
+ _dark: {
+ fill: backgroundColor
+ ? `${backgroundColor}`
+ : "dark.ui.link.primary-05",
+ },
+ },
+ },
+ };
+ return {
+ base: {
+ ".selectedItem": {
+ color: highlightOrLinkColor,
+ fontWeight: "bold",
+ backgroundColor: finalBackgroundColor,
+ "&:hover": {
+ color: highlightOrLinkColor,
+ },
+ },
+ borderBottom: "1px solid",
+ borderColor: highlightOrBorderColor,
+ display: "flex",
+ justifyContent: "center",
+ },
+ container: {
+ maxWidth: "1280px",
+ px: { base: "0", md: "xs" },
+ width: "100%",
+ },
+ scrollableList: {
+ display: "flex",
+ overflowX: "auto",
+ whiteSpace: "nowrap",
+ position: "relative",
+ scrollbarWidth: "none",
+ },
+ primaryActions: {
+ ...ulStyles,
+ width: "100%",
+ button: {
+ color: highlightOrDefaultColor,
+ ...primaryActionsStyles,
+ },
+ a: {
+ color: `${highlightOrDefaultColor}`,
+ ...primaryActionsStyles,
+ svg: {
+ fill: `${highlightOrDefaultColor}`,
+ margin: { base: "0", md: null },
+ _dark: {
+ fill: "ui.white !important",
+ },
+ },
+ },
+ },
+ secondaryActions: {
+ ...ulStyles,
+ width: "fit-content",
+ whiteSpace: "nowrap",
+ button: secondaryActionsStyles,
+ a: secondaryActionsStyles,
+ },
+ fadeEffect: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ height: "100%",
+ width: "50px",
+ background:
+ "linear-gradient(to left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%)",
+ pointerEvents: "none",
+ zIndex: 1,
+ },
+ primaryList: {
+ position: "relative",
+ display: "flex",
+ width: "100%",
+ overflowX: "auto",
+ },
+ };
+ }
+ ),
+});
+
+const SubNavChildren = subNavChildrenDefineMultiStyleConfig({
+ baseStyle: subNavChildrenDefinePartsStyle(
+ ({ isOutlined }: SubNavChildrenStyleProps) => {
+ return {
+ outLine: {
+ border: isOutlined !== undefined ? "1px solid" : "none",
+ borderRadius: "6px",
+ },
+ };
+ }
+ ),
+});
+
+export { SubNav, SubNavChildren };
diff --git a/src/theme/index.ts b/src/theme/index.ts
index f889781cd7..2044aead14 100644
--- a/src/theme/index.ts
+++ b/src/theme/index.ts
@@ -67,6 +67,7 @@ import StatusBadge from "./components/statusBadge";
import StructuredContent from "./components/structuredContent";
import StyledList from "./components/styledList";
import SocialMediaLinks from "./components/socialmedialinks";
+import { SubNav, SubNavChildren } from "./components/subnav";
import Tabs from "./components/tabs";
import TagSetStyles from "./components/tagSet";
import TemplateStyles from "./components/template";
@@ -168,6 +169,8 @@ const theme: any = {
StructuredContent,
StyledList,
SocialMediaLinks,
+ SubNav,
+ SubNavChildren,
Tabs,
CustomTable,
...TagSetStyles,