Føx is the web development branch of Kvåle Solutions. Føx specializes in building lightweight, performant, and semantic websites with no frameworks. A Føx website is like a fox: quick, light and shrewd.
Website creation goes far beyond mere programming. If you're using Føx, you don't have to go far to find the other services you need. Kvåle Solutions has various other departments, providing graphic design, typesetting and language services. Your project can be completed in full by Kvåle Solutions, all with a single point of contact (SPOC).
Føx made the very website you're on. Feel free to explore it, and if you are interested in the process behind making it, simply scroll down to view the developer's summary.
Building the Kvåle Solutions Website
The Kvåle Solutions website is a complete graph; every webpage is reachable from every other webpage. This design reflects the interconnectivity of these four main services, and makes it easy for clients to navigate the website.
It is also translated into nynorsk and bokmål, handled with
/nn/ and /nb/
subdirectories.
We took a somewhat unconventional approach with the PC-version of the front page. We wanted each service card to be big and visible, and we also wanted the front page to be no-scroll. Meeting both of those desiderata was impossible when we tried to put the main Kvåle Solutions logo in the header. So... we put it in the center instead. Such unconventional design might throw off some prospective clients, but that is a risk worth taking. We live in an attention economy, and if you combine unconventionality with high quality, you will win the attention game.
The mobile version of the front page is a scrollable page with a normal layout however, as the size constraints simply made the other approach unwise. We always consider the constraints of different media and design our sites accordingly, keeping a consistent style but also never letting the constraints of either medium hold us back when designing for the other.
Every image across the entire website is a .svg, thanks to Æxis and TeXtract supplying us with their vector-based graphics.
The most technically advanced page is that of Aexis, which features a Bézier curve that transforms as a function of the visitor's scrolling down the page.
const track = document.querySelector('.animation-track');
if (track) {
const mainCurve = document.getElementById('mainCurve');
const dash1 = document.getElementById('dash1');
const dash2 = document.getElementById('dash2');
const cp1 = document.getElementById('cp1');
const cp2 = document.getElementById('cp2');
let ticking = false;
const updateCurve = () => {
const rect = track.getBoundingClientRect();
const progress = Math.max(0, (window.innerHeight / 2) - rect.top);
// Found these two through tweaks, striking a balance between letting the curve bend
// but not taking too much time
const baseMove = Math.min(400, progress * 0.8);
// Since I wanted the curve and the control points to be visible at the end,
// I was more constrained vertically than horizontally,
// so I made the control points move more in the horizontal direction
const moveY = baseMove * 0.55;
const moveX = baseMove * 2;
// I made the move in opposite directions for the most dramatic bend
const newCy1 = 250 - moveY;
const newCy2 = 250 + moveY;
const newCx1 = 400 + moveX;
const newCx2 = 600 - moveX;
cp1.setAttribute('cx', newCx1);
cp1.setAttribute('cy', newCy1);
cp2.setAttribute('cx', newCx2);
cp2.setAttribute('cy', newCy2);
dash1.setAttribute('x2', newCx1);
dash1.setAttribute('y2', newCy1);
dash2.setAttribute('x2', newCx2);
dash2.setAttribute('y2', newCy2);
mainCurve.setAttribute('d', `M 100 250 C ${newCx1} ${newCy1}, ${newCx2} ${newCy2}, 900 250`);
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateCurve);
ticking = true;
}
};
const observerOptions = {
root: null,
rootMargin: '100px',
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
window.addEventListener('scroll', onScroll, { passive: true });
} else {
window.removeEventListener('scroll', onScroll);
}
});
}, observerOptions);
observer.observe(track);
}
Aexis also features a 2,699x zoom into a LaTeX-generated .svg of a Fibonacci tiling, fully coded in vanilla JS.
const zoomBtn = document.getElementById('zoom-trigger-btn');
if (zoomBtn) {
const fibSvg = document.getElementById('fibonacci-svg');
// The coordinates of the innermost square were found manually via DevTools.
const fStart = { x: 12.79, y: 12.04, w: 539.86, h: 321.62 };
const fTarget = { x: 387.571, y: 244.817 };
const startCenter = { x: fStart.x + (fStart.w / 2), y: fStart.y + (fStart.h / 2) };
const fEndWidth = 0.2;
const fDuration = 17000;
const fPauseTime = 2500;
const fEndHeight = (fEndWidth * fStart.h) / fStart.w;
let fStartTime = null;
let isZooming = false;
const formatX = (num) => Math.round(num).toLocaleString() + "x";
function runFibonacciZoom(timestamp) {
if (!fStartTime) fStartTime = timestamp;
const elapsed = timestamp - fStartTime;
let directionProgress;
let isPaused = false;
if (elapsed < fDuration) {
directionProgress = elapsed / fDuration;
} else if (elapsed < fDuration + fPauseTime) {
directionProgress = 1;
isPaused = true;
} else if (elapsed < (fDuration * 2) + fPauseTime) {
const timeIntoOutro = elapsed - fDuration - fPauseTime;
directionProgress = 1 - (timeIntoOutro / fDuration);
} else {
stopZoom();
return;
}
// I had to add a glide over to the target coordinates
// to avoid an ugly snap at the start of the animation
const glideEase = 1 - Math.pow(1 - directionProgress, 10);
const zoomEase = Math.pow(directionProgress, 2);
const currentW = fStart.w * Math.pow((fEndWidth / fStart.w), zoomEase);
const currentH = fStart.h * Math.pow((fEndHeight / fStart.h), zoomEase);
const multiplier = fStart.w / currentW;
if (isPaused) {
zoomBtn.textContent = `${pageText.maxZoom}${formatX(multiplier)}`;
} else {
zoomBtn.textContent = `${pageText.zooming}${formatX(multiplier)}`;
}
const currentCenterX = startCenter.x + (fTarget.x - startCenter.x) * glideEase;
const currentCenterY = startCenter.y + (fTarget.y - startCenter.y) * glideEase;
const currentX = currentCenterX - (currentW / 2);
const currentY = currentCenterY - (currentH / 2);
fibSvg.setAttribute('viewBox', `${currentX} ${currentY} ${currentW} ${currentH}`);
if (isZooming) {
requestAnimationFrame(runFibonacciZoom);
}
}
function stopZoom() {
isZooming = false;
fStartTime = null;
zoomBtn.textContent = pageText.diveBackIn;
zoomBtn.style.opacity = "1";
zoomBtn.style.pointerEvents = "auto";
fibSvg.setAttribute('viewBox', `${fStart.x} ${fStart.y} ${fStart.w} ${fStart.h}`);
}
zoomBtn.addEventListener('click', () => {
if (isZooming) return;
isZooming = true;
fStartTime = null;
zoomBtn.style.opacity = "0.9";
zoomBtn.style.pointerEvents = "none";
requestAnimationFrame(runFibonacciZoom);
});
}